---
/**
* 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>