# Vanish Input (Moonarc)

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.

- Import: `import VanishInput from '@moonarc/core/VanishInput'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/vanish-input.json`
- Tier B · category ui · trigger click, always
- Readout: `<VanishInput placeholders={hints} name="email">`
- Browser support: newly (Chrome 125 · Firefox 128 · Safari 16.4); elsewhere: without @property or round() only the first placeholder shows; without :has() the native placeholder shows through the overlay
- Measured cost: 604 B raw JS · 375 B gzip · + runtime (with dependencies 2.3 kB raw; CSS 7.3 kB raw)
- Page: https://moonarc.dev/components/vanish-input/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `placeholders` | `string[]` | none | Suggestions shown in turn. The first is the placeholder attribute, the one a screen reader announces and a page without CSS shows. |
| `name` | `string` | `'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. |
| `label` | `string` | none | Visible label as <label for>. Without it, pass aria-label. |
| `id` | `string` | `unique per render` | The 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. |
| `autocomplete` | `string` | `'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-radius` | `CSS length (custom property)` | `999px` | The 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-invalid` | `CSS colour (custom property)` | `#e0563f` | The 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. |

## Usage

```astro
<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>
```

## 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.

## Craft

- The placeholder attribute is real and carries the first suggestion: assistive tech reads it, a page without CSS shows it, and :placeholder-shown keeps working. The cycling copies are an overlay a screen reader never meets.
- The cycle is one registered number 0 → 1 over n slots; each suggestion computes k = round(down, t × n) and is on when k equals its index, through a clamped square so abs() is not needed. Because on/off is a computed value, opacity and translate are transitions on the preset curve, and the keyframes are the same for three suggestions or ten.
- The submit listener is on the document, in the bubbling phase, on purpose: listeners on the form run in registration order, so a listener there could run before your handler and read defaultPrevented too early. On the document it always runs after everything on the form. It never calls preventDefault() itself: a form without a handler navigates as it should.
- The vanish is CSS: one span per character, each with its index as a custom property, blur + rise + fade on the exit curve, delayed by index on the tight stagger token. The script only builds the spans, clears the field and removes them on animationend.
- A handler that prevents the default after an await is too late for the animation (the event has finished dispatching). This is stated here rather than worked around.
- The hints and the placeholder are secondary text at --ma-dim, like every other secondary text in the library, and the dimming is in the colour (the ink mixed toward transparent in oklab) and not in opacity, because opacity is what shows and hides them.
- The error border is :user-invalid, so it never shows before the reader has typed or submitted, and not while the field's form carries data-pending, data-state="done" or data-state="exists": the vanish empties a required field inside the submit dispatch, and an address that is being sent, has been, or was already on the list, is not an error. A field outside a form, or in a form without those attributes, keeps the red border as usual. The red is --ma-vanish-invalid when set: a fixed hue cannot keep 3 : 1 on every ground, so a caller on a coloured panel gives its own.

## Replaces

- PlaceholdersAndVanishInput (Aceternity)
- VanishInput (Cult UI)

## Source

```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>

```
