Skip to content

Components / UI

Vanish Input

Placeholders that take turns, and text that blurs away on submit. A native input in your form: the first suggestion is the placeholder attribute, the rest cycle in an aria-hidden overlay driven by one registered number, so their entrances are transitions on the preset curve. A small script listens for submit on the document and, only when your handler prevented the default, splits the value into characters that rise and blur out on the stagger token.

Live demo

hints take turns · Enter sends · the words blur away

Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.

Measured

JavaScript of its own

604 B raw

375 B gzip.

Uses the shared runtime (1.9 kB raw, once per site). With those included: 2.3 kB raw.

CSS 7.3 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.

Browser support

Baseline · newly available

Chrome 125 · Firefox 128 · Safari 16.4; needs property, has, css-math. Elsewhere: without @property or round() only the first placeholder shows; without :has() the native placeholder shows through the overlay.

Install

One command adds the integration, the base tokens and every component. Then import what you use.

npx astro add moonarc
pnpm astro add moonarc
bunx astro add moonarc
src/pages/index.astro
---
import VanishInput from '@moonarc/core/VanishInput';
---
<form data-waitlist>
  <VanishInput type="email" name="email" label="Email" autocomplete="email" placeholders={['you@studio.example', 'Join 2,398 on the list', 'No spam, one mail at launch']} required />
  <button type="submit">Join</button>
</form>

<script>
  // your handler prevents the navigation: that is what starts the vanish
  document.querySelector('[data-waitlist]').addEventListener('submit', (e) => {
    e.preventDefault();
    fetch('/api/waitlist', { method: 'POST', body: new FormData(e.currentTarget) });
  });
</script>
Copy it into your project instead (shadcn registry)

Owns the file, no dependency. The registry item also installs the shared runtime and the base tokens.

terminal
npx shadcn@latest add https://moonarc.dev/r/vanish-input.json

The CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.

Source

The whole component. Self-contained styles in a cascade layer so your classes always win. If you paste it, also copy runtime.ts.

VanishInput.astro
---
/**
 * VanishInput — placeholders that take turns, and text that blurs away on
 * submit. A native <input> inside your form; the `placeholder` attribute
 * carries the first suggestion (what a screen reader and a page without
 * CSS get) and an aria-hidden overlay cycles through the rest: one
 * registered number runs 0 → 1 over the whole cycle and each suggestion
 * computes its own on/off from it (the AnimatedList technique), so the
 * entrances and exits are transitions on the preset curve and no keyframe
 * knows how many there are. The overlay hides on focus and once there is a
 * value. The vanish is a small script listening for `submit` on
 * `document`: when your handler has called preventDefault() (a fetch), the
 * value is copied into one span per character, each blurs, rises and fades
 * on the stagger token, the input is cleared, and the spans go once the
 * animation ends. When nothing prevented the default the page is about to
 * leave and the script does nothing.
 */
import type { HTMLAttributes } from 'astro/types';

interface Props extends Omit<HTMLAttributes<'input'>, 'type' | 'placeholder'> {
  /** Suggestions shown in turn; the first is the placeholder attribute. */
  placeholders: string[];
  name?: string;
  /** Text-like types only: the characters must be visible to vanish. */
  type?: 'text' | 'email' | 'search' | 'url';
  /** Visible label as <label for>. Without it, pass aria-label. */
  label?: string;
  /** The input's id and the label's for. Unset, unique per render. */
  id?: string;
  /** Off by default, so the browser's suggestion list does not cover the hints; yours replaces it (one attribute either way). */
  autocomplete?: string | null;
}

const { placeholders, name = 'q', type = 'text', label, id, autocomplete = 'off', class: className, style, ...rest } = Astro.props;
const list = placeholders.length ? placeholders : ['Type here'];
// unique per render, not per name: two waitlist forms on one page both name their field "email"
const inputId = id ?? `ma-vanish-${name}-${Math.random().toString(36).slice(2, 7)}`;
const vars = [`--ma-vanish-n:${list.length}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
---

{label && <label class="ma-vanish__label" for={inputId}>{label}</label>}
<span class:list={['ma-vanish', className]} style={vars} data-ma-vanish>
  <input id={inputId} class="ma-vanish__input" type={type} name={name} placeholder={list[0]} autocomplete={autocomplete} {...rest} />
  {list.length > 1 && (
    <span class="ma-vanish__cycle" aria-hidden="true">
      {list.map((p, i) => <span class="ma-vanish__hint" style={`--ma-vanish-i:${i}`}>{p}</span>)}
    </span>
  )}
  <span class="ma-vanish__ghost" aria-hidden="true"></span>
</span>

<style is:global>
  @property --ma-vanish-t {
    syntax: '<number>';
    inherits: true;
    initial-value: 0;
  }
  @keyframes ma-vanish-cycle {
    from {
      --ma-vanish-t: 0;
    }
    to {
      --ma-vanish-t: 1;
    }
  }
  @keyframes ma-vanish-out {
    to {
      opacity: 0;
      translate: 0 calc(var(--ma-travel-enter) * -1);
      filter: blur(6px);
    }
  }
  @layer components {
    :where(.ma-vanish) {
      /* one suggestion per slot; the whole cycle is n slots, on the ambient duration doubled */
      --ma-vanish-slot: calc(var(--ma-dur-ambient) * 2);
      position: relative;
      display: inline-grid;
      inline-size: 100%;
      color: inherit;
    }
    :where(.ma-vanish__label) {
      display: block;
      margin-block-end: 0.5em;
      font-size: 0.875em;
    }
    :where(.ma-vanish__input) {
      grid-area: 1 / 1;
      inline-size: 100%;
      margin: 0;
      padding: 0.7em 1em;
      border: 1px solid var(--ma-edge);
      /* a pill unless you say otherwise: set --ma-vanish-radius on the component or any ancestor */
      border-radius: var(--ma-vanish-radius, 999px);
      background: var(--ma-panel);
      color: inherit;
      font: inherit;
      line-height: 1.4;
      appearance: none;
      -webkit-appearance: none;
      transition: border-color var(--ma-duration-fast) var(--ma-ease-out);
    }
    :where(.ma-vanish__input:focus-visible) {
      outline: 2px solid var(--ma-ink);
      outline-offset: 2px;
    }
    /* the vanish empties a required field in the submit dispatch itself, so a form that is sending or has sent (done, or
       exists: already on the list) is not an error. The red is a hook: a caller on a coloured ground sets
       --ma-vanish-invalid to a colour that keeps 3 : 1 there */
    :where(.ma-vanish__input:user-invalid:not(form[data-pending] *, form[data-state='done'] *, form[data-state='exists'] *)) {
      border-color: var(--ma-vanish-invalid, #e0563f);
    }
    :where(.ma-vanish__input:disabled) {
      opacity: 0.5;
      cursor: not-allowed;
    }
    /* secondary text at --ma-dim through the colour: opacity is what shows and hides the placeholder and the hints */
    :where(.ma-vanish__input)::placeholder {
      color: color-mix(in oklab, var(--ma-ink) calc(var(--ma-dim, 0.7) * 100%), transparent);
      opacity: 1;
      transition: opacity var(--ma-duration-fast) var(--ma-ease-out);
    }
    /* while the overlay cycles the native placeholder is invisible; on focus, or with a value, it is the other way round */
    :where(.ma-vanish:has(.ma-vanish__cycle) .ma-vanish__input:not(:focus):placeholder-shown)::placeholder {
      opacity: 0;
    }
    :where(.ma-vanish__cycle) {
      grid-area: 1 / 1;
      display: grid;
      align-items: center;
      padding: 0.7em 1em;
      border: 1px solid transparent;
      line-height: 1.4;
      color: color-mix(in oklab, var(--ma-ink) calc(var(--ma-dim, 0.7) * 100%), transparent);
      overflow: clip;
      pointer-events: none;
      animation: ma-vanish-cycle calc(var(--ma-vanish-n) * var(--ma-vanish-slot)) linear infinite;
    }
    :where(.ma-vanish__hint) {
      /* the slot on now: k = floor(t × n); this one is on when k = i — a square, so no abs(): 1 − (k − i)² is 1 at k = i and ≤ 0 elsewhere */
      --ma-vanish-k: round(down, var(--ma-vanish-t) * var(--ma-vanish-n));
      --ma-vanish-on: clamp(0, 1 - (var(--ma-vanish-k) - var(--ma-vanish-i)) * (var(--ma-vanish-k) - var(--ma-vanish-i)), 1);
      grid-area: 1 / 1;
      white-space: nowrap;
      overflow: clip;
      text-overflow: ellipsis;
      opacity: var(--ma-vanish-on);
      translate: 0 calc((1 - var(--ma-vanish-on)) * var(--ma-travel-hover) * 2);
      transition:
        opacity var(--ma-duration) var(--ma-ease-out),
        translate var(--ma-duration) var(--ma-ease);
    }
    :where(.ma-vanish:has(.ma-vanish__input:focus) .ma-vanish__cycle),
    :where(.ma-vanish:has(.ma-vanish__input:not(:placeholder-shown)) .ma-vanish__cycle) {
      opacity: 0;
      transition: opacity var(--ma-duration-fast) var(--ma-ease-out);
    }
    /* the vanish: one span per character over the field, blurred away on the exit curve, staggered on the tight token */
    :where(.ma-vanish__ghost) {
      grid-area: 1 / 1;
      display: flex;
      align-items: center;
      padding: 0.7em 1em;
      border: 1px solid transparent;
      line-height: 1.4;
      white-space: pre;
      overflow: clip;
      pointer-events: none;
    }
    :where(.ma-vanish__ghost > i) {
      font-style: normal;
      animation: ma-vanish-out var(--ma-duration) var(--ma-ease-in) calc(var(--ma-vanish-i) * var(--ma-stagger-tight)) both;
    }
    /* while the characters leave, the field shows nothing of its own — no placeholder, no cycle — so the exit is the only thing moving */
    :where(.ma-vanish[data-vanish] .ma-vanish__input)::placeholder,
    :where(.ma-vanish[data-vanish] .ma-vanish__cycle) {
      opacity: 0;
    }
    @media (forced-colors: active) {
      :where(.ma-vanish__input) {
        border-color: ButtonText;
      }
    }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-vanish__cycle) {
        animation: none;
        display: none;
      }
      :where(.ma-vanish:has(.ma-vanish__cycle) .ma-vanish__input:not(:focus):placeholder-shown)::placeholder {
        opacity: 1;
      }
      :where(.ma-vanish__ghost > i) {
        animation-duration: var(--ma-duration-fast);
        animation-delay: 0s;
        filter: none;
        translate: 0 0;
      }
    }
  }
</style>

<script>
  import { onMount, prefersReducedMotion } from '../lib/runtime';

  onMount<HTMLElement>('[data-ma-vanish]', (box, { signal }) => {
    const input = box.querySelector('input')!;
    const ghost = box.querySelector<HTMLElement>('.ma-vanish__ghost')!;
    // on document, bubbling: it runs after every listener on the form itself, whatever the registration order, so defaultPrevented is final
    document.addEventListener(
      'submit',
      (e) => {
        if (e.target !== input.form || !e.defaultPrevented || !input.value) return;
        if (prefersReducedMotion()) {
          input.value = '';
          return;
        }
        ghost.replaceChildren(
          ...[...input.value].map((ch, i) => {
            const s = document.createElement('i');
            s.textContent = ch;
            s.style.setProperty('--ma-vanish-i', String(i));
            return s;
          }),
        );
        input.value = '';
        box.setAttribute('data-vanish', '');
        const last = ghost.lastElementChild!;
        last.addEventListener(
          'animationend',
          () => {
            ghost.replaceChildren();
            box.removeAttribute('data-vanish');
          },
          { once: true },
        );
      },
      { signal },
    );
  });
</script>

Props

PropTypeDefaultDescription
placeholdersstring[]noneSuggestions shown in turn. The first is the placeholder attribute, the one a screen reader announces and a page without CSS shows.
namestring'q'Form field name.
type'text' | 'email' | 'search' | 'url''text'Text-like types only: the characters must be visible to vanish, so password is not one of them.
labelstringnoneVisible label as <label for>. Without it, pass aria-label.
idstringunique per renderThe input's id, and the label's for. Unset, ma-vanish- with the name and a random suffix, so two forms on one page with a field of the same name never share an id. Yours wins.
autocompletestring'off'Off by default, so the browser's suggestion list does not cover the cycling hints. Yours replaces it (the input carries one autocomplete attribute either way), so a waitlist field passes autocomplete="email".
--ma-vanish-radiusCSS length (custom property)999pxThe field's corner radius, a pill by default. Set it in a rule or style on the component or any ancestor, e.g. var(--ma-radius-md, 0.5rem) to follow a theme's radius.
--ma-vanish-invalidCSS colour (custom property)#e0563fThe border of a field the reader left invalid (:user-invalid). Set it in a rule or style on the component or any ancestor that knows its ground: a red that keeps 3 : 1 against it (WCAG 1.4.11), e.g. the theme's --destructive mixed with the ground's text on a coloured panel, where the fixed red falls to 2 : 1.

Reduced motion

No cycle (the first placeholder stands), and on submit the field simply clears.

With ClientRouter

Bound through the shared runtime; the document listener is removed before a swap and set again after it.

Why it is built this way

Replaces: PlaceholdersAndVanishInput (Aceternity) · VanishInput (Cult UI). See the migration table.

grows with the text · Enter sends · ⇧Enter breaks the line

The AI prompt box: a form with a textarea that grows with its content through field-sizing, a model select, an attachment button that is a real file input, and a send button that dims until something is typed and spins while the form carries data-pending.

newly · Chrome 123 · Firefox 152 · Safari 26.2click

idle → pending → done · one attribute

One submit button with four faces (idle, pending with a spinner, done with a check that draws itself, error) and a width that follows the label.

every browserclick

Types a string one character at a time in any font, with a caret that stays solid while typing and blinks once idle.

Chrome 116 · not Firefox · Safari 18load

Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown