Gummy Type Input
An email field whose letters drop in and land: each typed letter falls from a little above, stretched as it falls and squashed as it lands, while the real input underneath keeps the caret, selection and autofill.
Props
"use client";
import { useEffect, useId, useRef, useState, useSyncExternalStore } from "react";
import type { CSSProperties, FormEvent, KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent, ReactNode, RefObject } from "react";
/**
* GummyTypeInput — an email field whose letters drop in and land.
*
* Each typed letter falls in from a little above, stretched as it falls and
* squashed as it lands. The native input stays underneath doing the real
* work. A bad address wobbles the pill and says why; given onSubmit, a good
* one hops out and a thank-you shows, and without it the field says so.
*
* Part of the Tactile Maximalism kit: dough #f7f0e4, warm ink #17130f and
* one loud accent, bubblegum #ff2d78, filled once per screen; grain fixed to
* the material; soft shadows tinted by what casts them, never a hard offset.
* Everything soft keeps its area as it squashes and settles inside 450ms.
* Unbounded through var(--font-unbounded) for display over Geist. Respects
* prefers-reduced-motion. No dependencies beyond React. Paste it as its own
* file: it repeats the kit's small helpers, which would clash in one module.
* Load Unbounded with next/font (variable: "--font-unbounded") on a
* parent, or from Google Fonts or @fontsource.
*/
const SANS = 'var(--font-sans, "Geist", "Inter", ui-sans-serif, system-ui, sans-serif)';
const MONO = 'var(--font-mono, "Geist Mono", ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace)';
const DISPLAY = 'var(--font-unbounded, "Unbounded", var(--font-sans, "Geist"), ui-sans-serif, system-ui, sans-serif)';
/** Dough, warm ink, and one loud accent. */
const DOUGH = {
ground: "#f7f0e4",
surface: "#fffaf1",
well: "#efe5d3",
solid: "#17130f",
ink: "rgba(23,19,15,0.92)",
ink2: "rgba(23,19,15,0.64)",
ink3: "rgba(23,19,15,0.42)",
line: "rgba(23,19,15,0.1)",
line2: "rgba(23,19,15,0.18)",
gum: "#ff2d78",
gumDeep: "#d8155d",
lilac: "#e8e1f6",
} as const;
/** A soft object resting in dough: a tight contact shadow and a wide one tinted by the object. */
function softShadow(tint = "23,19,15", lift = 1): string {
return `0 ${(2 * lift).toFixed(1)}px ${(6 * lift).toFixed(1)}px rgba(23,19,15,0.1), 0 ${(18 * lift).toFixed(1)}px ${(40 * lift).toFixed(1)}px -8px rgba(${tint},${(0.2 * Math.min(1, lift)).toFixed(3)})`;
}
/** Pressed into the dough: both shadows collapse to one tight line. */
const PRESSED_SHADOW = "0 1px 3px rgba(23,19,15,0.14)";
function clamp(v: number, lo: number, hi: number): number {
return v < lo ? lo : v > hi ? hi : v;
}
/** Links: strip tabs and newlines, refuse backslashes, allow http(s), mailto, tel and same-site paths. */
function safeHref(raw: string): string {
const v = raw.replace(/[\t\n\r]/g, "").trim();
if (!v || v.includes("\\")) return "#";
if (/^(https?:|mailto:|tel:)/i.test(v)) return v;
if (/^[/#?]/.test(v) && !/^\/\//.test(v)) return v;
return "#";
}
function subscribeMotion(cb: () => void) {
const m = window.matchMedia("(prefers-reduced-motion: reduce)");
m.addEventListener("change", cb);
return () => m.removeEventListener("change", cb);
}
/** True when the visitor asked for reduced motion. False on the server. */
function useReducedMotion(): boolean {
return useSyncExternalStore(
subscribeMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
/**
* Squash and stretch for one element, on a spring. `press(depth)` squashes it
* onto its foot with its area kept (as short as it gets, it gets as wide);
* `release()` lets it spring back past rest, one visible overshoot, settled
* inside 450ms; `flick(v)` gives it a sideways jiggle (a skew) from a push.
* The transform is written straight to the element; nothing re-renders.
*/
function useSquish(ref: RefObject<HTMLElement | null>, origin = "50% 100%") {
const reduced = useReducedMotion();
const st = useRef({ y: 1, v: 0, t: 1, sk: 0, skv: 0, raf: 0, last: 0 });
const tickRef = useRef<(now: number) => void>(() => undefined);
useEffect(() => {
const s = st.current;
const el = ref.current;
if (el) {
el.style.transformOrigin = origin;
// The spring owns the transform; a stylesheet's transition would smear it.
el.style.transitionProperty = el.style.transitionProperty && el.style.transitionProperty !== "all" ? el.style.transitionProperty : "none";
}
tickRef.current = (now: number) => {
s.raf = 0;
const dt = s.last ? Math.min(1 / 30, (now - s.last) / 1000) : 1 / 60;
s.last = now;
// k 420, c 18: one overshoot of about 20%, settled inside ~440ms.
for (let i = 0; i < 3; i++) {
const h = dt / 3;
s.v += (420 * (s.t - s.y) - 18 * s.v) * h;
s.y += s.v * h;
s.skv += (-380 * s.sk - 20 * s.skv) * h;
s.sk += s.skv * h;
}
const e = ref.current;
const y = clamp(s.y, 0.5, 1.6);
if (e) e.style.transform = `scale(${(1 / y).toFixed(4)}, ${y.toFixed(4)}) skewX(${s.sk.toFixed(3)}deg)`;
const moving = Math.abs(s.v) > 0.002 || Math.abs(s.y - s.t) > 0.0015 || Math.abs(s.sk) > 0.02 || Math.abs(s.skv) > 0.05;
if (moving) s.raf = requestAnimationFrame((t) => tickRef.current(t));
else {
s.last = 0;
if (e && s.t === 1) {
s.y = 1;
s.sk = 0;
e.style.transform = "";
}
}
};
return () => {
cancelAnimationFrame(s.raf);
s.raf = 0;
s.last = 0;
};
}, [ref, origin]);
const kick = () => {
const s = st.current;
if (!s.raf) s.raf = requestAnimationFrame((t) => tickRef.current(t));
};
return {
press: (depth = 0.86) => {
if (reduced) return;
st.current.t = clamp(depth, 0.6, 1);
kick();
},
release: () => {
st.current.t = 1;
if (!reduced) kick();
},
flick: (v: number) => {
if (reduced) return;
st.current.skv += clamp(v, -1, 1) * 160;
kick();
},
};
}
// Each part carries only the CSS it needs, so any one of them works on its own.
// revert-layer keeps an element's own rounded corners when a host page's focus rule flattens them.
const FOCUS_CSS = ".tm-scope :focus-visible,.tm-scope:focus-visible{outline:2.5px solid var(--tm-ring,#17130f)!important;outline-offset:3px;border-radius:revert-layer}";
const TM_BUTTON_INK = {
gum: { bg: DOUGH.gum, fg: "#fffaf1", tint: "255,45,120", border: "transparent" },
ink: { bg: DOUGH.solid, fg: "#fffaf1", tint: "23,19,15", border: "transparent" },
soft: { bg: DOUGH.surface, fg: DOUGH.solid, tint: "23,19,15", border: "rgba(23,19,15,0.1)" },
} as const;
export type TmButtonVariant = keyof typeof TM_BUTTON_INK;
/**
* SquashPressButton — a pill that squashes under your finger and pops back.
*
* Press and it squashes onto its base where you pressed, its area kept (as it
* gets shorter it gets wider); hold and it sinks further, up to a deep press
* at 600ms; let go and it springs back past rest, one overshoot, settled
* inside 450ms. Its two shadows (a contact and a wide one tinted by the pill's
* own colour) collapse to one tight line while pressed. Swipe across it and it
* jiggles the way you pushed it. Gum, ink and soft; a link when given an href.
*/
export function SquashPressButton({
children,
href,
onClick,
type = "button",
variant = "ink",
size = "lg",
disabled = false,
ariaLabel,
className = "",
}: {
children: ReactNode;
href?: string;
onClick?: () => void;
type?: "button" | "submit";
variant?: TmButtonVariant;
size?: "lg" | "md" | "sm";
disabled?: boolean;
ariaLabel?: string;
className?: string;
}) {
const ink = TM_BUTTON_INK[variant] ?? TM_BUTTON_INK.ink;
const bodyRef = useRef<HTMLSpanElement>(null);
const squish = useSquish(bodyRef);
const [state, setState] = useState<"rest" | "hover" | "down">("rest");
const hold = useRef({ t0: 0, raf: 0, x: 0, t: 0 });
// Holding sinks it further: 0.9 at the press, 0.8 by 600ms.
useEffect(() => {
const h = hold.current;
return () => {
cancelAnimationFrame(h.raf);
h.raf = 0;
};
}, []);
const deepen = () => {
const h = hold.current;
const run = (now: number) => {
h.raf = 0;
const k = clamp((now - h.t0) / 600, 0, 1);
squish.press(0.9 - 0.1 * k);
if (k < 1) h.raf = requestAnimationFrame(run);
};
cancelAnimationFrame(h.raf);
h.raf = requestAnimationFrame(run);
};
const down = (xPct: number) => {
// It squashes onto its base under your finger, not about its middle.
if (bodyRef.current) bodyRef.current.style.transformOrigin = `${clamp(xPct, 15, 85).toFixed(0)}% 100%`;
setState("down");
hold.current.t0 = performance.now();
squish.press(0.9);
deepen();
};
const up = (to: "rest" | "hover") => {
cancelAnimationFrame(hold.current.raf);
hold.current.raf = 0;
setState(to);
squish.release();
};
const pad = size === "sm" ? "h-9 px-4 text-[12.5px]" : size === "md" ? "h-11 px-5 text-[13.5px]" : "h-14 px-7 text-[15px]";
const shadow = state === "down" ? PRESSED_SHADOW : softShadow(ink.tint, state === "hover" ? 1.3 : 1);
const handlers = {
"aria-label": ariaLabel,
className: "tm-scope group relative inline-flex select-none rounded-full no-underline outline-none " + (disabled ? "pointer-events-none opacity-40 " : "") + className,
style: { WebkitTapHighlightColor: "transparent", touchAction: "manipulation" } as CSSProperties,
onPointerEnter: (e: ReactPointerEvent<HTMLElement>) => {
setState("hover");
hold.current.x = e.clientX;
hold.current.t = performance.now();
},
onPointerMove: (e: ReactPointerEvent<HTMLElement>) => {
hold.current.x = e.clientX;
hold.current.t = performance.now();
},
onPointerLeave: (e: ReactPointerEvent<HTMLElement>) => {
const dt = Math.max(8, performance.now() - hold.current.t);
// A quick swipe out leaves it jiggling the way it was pushed.
squish.flick(((e.clientX - hold.current.x) / dt) * 0.6);
up("rest");
},
onPointerDown: (e: ReactPointerEvent<HTMLElement>) => {
if (e.button !== 0) return;
const r = e.currentTarget.getBoundingClientRect();
down(((e.clientX - r.left) / Math.max(1, r.width)) * 100);
},
onPointerUp: () => up("hover"),
onPointerCancel: () => up("rest"),
onKeyDown: (e: ReactKeyboardEvent<HTMLElement>) => {
if (e.repeat) return;
if (e.key === "Enter" || (e.key === " " && !href)) down(50);
},
onKeyUp: () => {
if (state === "down") up("rest");
},
onBlur: () => {
if (state === "down") up("rest");
},
};
// The pill that squashes and its shadow live on an inner span, so the hit area never moves.
const pill = (
<span
ref={bodyRef}
className={"relative inline-flex items-center justify-center rounded-full " + pad}
style={{
background: ink.bg,
color: ink.fg,
border: `1px solid ${ink.border}`,
boxShadow: shadow,
fontFamily: DISPLAY,
fontWeight: 600,
letterSpacing: "-0.01em",
transition: "box-shadow 180ms cubic-bezier(0.2, 0.8, 0.2, 1)",
willChange: "transform",
}}
>
<span className="whitespace-nowrap">{children}</span>
</span>
);
if (href) {
return (
<>
<style>{FOCUS_CSS}</style>
<a href={safeHref(href)} onClick={onClick} {...handlers}>
{pill}
</a>
</>
);
}
return (
<>
<style>{FOCUS_CSS}</style>
<button type={type} onClick={onClick} disabled={disabled} {...handlers}>
{pill}
</button>
</>
);
}
/**
* GummyTypeInput — an email field whose letters drop in and land.
*
* Each letter you type falls into the field from a little above, stretched
* as it falls and squashed as it lands (its area kept), then settles, inside
* 450ms. The native input stays underneath doing the real work (the caret,
* selection, autofill, paste); only its drawing is replaced. Submit and the
* pill squashes under the button; a bad address makes it wobble and says
* why. Given `onSubmit`, a good address hops out of the field and a thank-you
* shows; without it, the field says the address is fine but nothing saved it.
*/
export function GummyTypeInput({
label = "Get early access",
placeholder = "you@yourband.com",
button = "Join the list",
success = "You're on the list. We'll write when drops open.",
onSubmit,
className = "",
}: {
label?: string;
placeholder?: string;
button?: string;
success?: string;
/** Called with a valid address; may return a promise. Throw to show an error. */
onSubmit?: (email: string) => void | Promise<void>;
className?: string;
}) {
const id = useId();
const [value, setValue] = useState("");
const [status, setStatus] = useState<{ tone: "ok" | "err" | "note"; text: string } | null>(null);
const [busy, setBusy] = useState(false);
const [hopping, setHopping] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const trackRef = useRef<HTMLSpanElement>(null);
const pillRef = useRef<HTMLDivElement>(null);
const charRefs = useRef<(HTMLSpanElement | null)[]>([]);
const pill = useSquish(pillRef);
const reduced = useReducedMotion();
const sim = useRef({ y: [] as number[], vy: [] as number[], s: [] as number[], sv: [] as number[], raf: 0, last: 0, prev: "" });
const tickRef = useRef<(now: number) => void>(() => undefined);
// A submit can outlive the field (a route change mid-request): nothing is set once it has gone.
const alive = useRef(true);
const hop = useRef(0);
useEffect(() => {
alive.current = true;
return () => {
alive.current = false;
window.clearTimeout(hop.current);
};
}, []);
useEffect(() => {
const st = sim.current;
tickRef.current = (now: number) => {
st.raf = 0;
const dt = st.last ? Math.min(1 / 30, (now - st.last) / 1000) : 1 / 60;
st.last = now;
let moving = false;
for (let i = 0; i < st.y.length; i++) {
for (let k = 0; k < 3; k++) {
const h = dt / 3;
st.vy[i] += (420 * (0 - st.y[i]) - 20 * st.vy[i]) * h;
st.y[i] += st.vy[i] * h;
// Falling fast it stretches; the shape's own spring overshoots into a squash as it lands.
st.sv[i] += (520 * (1 + clamp(st.vy[i] * 0.0011, -0.25, 0.3) - st.s[i]) - 18 * st.sv[i]) * h;
st.s[i] += st.sv[i] * h;
}
const el = charRefs.current[i];
if (el) el.style.transform = `translateY(${st.y[i].toFixed(2)}px) scale(${(1 / st.s[i]).toFixed(4)}, ${st.s[i].toFixed(4)})`;
if (Math.abs(st.y[i]) > 0.1 || Math.abs(st.vy[i]) > 1 || Math.abs(st.s[i] - 1) > 0.002 || Math.abs(st.sv[i]) > 0.02) moving = true;
}
if (moving) st.raf = requestAnimationFrame((n) => tickRef.current(n));
else st.last = 0;
};
return () => {
cancelAnimationFrame(st.raf);
st.raf = 0;
st.last = 0;
};
}, []);
// Keep the drawn letters over the native ones when the field scrolls sideways.
const sync = () => {
const i = inputRef.current;
const t = trackRef.current;
if (i && t) t.style.transform = `translateX(${-i.scrollLeft}px)`;
};
// New letters (everything after the first change) drop in from above.
useEffect(() => {
const st = sim.current;
const chars = [...value];
const was = [...st.prev];
let from = 0;
while (from < chars.length && from < was.length && chars[from] === was[from]) from++;
st.y.length = st.vy.length = st.s.length = st.sv.length = chars.length;
for (let i = 0; i < chars.length; i++) {
if (st.y[i] === undefined || i >= from) {
const fresh = i >= from && chars.length > was.length && !reduced;
st.y[i] = fresh ? -16 : 0;
st.vy[i] = 0;
st.s[i] = 1;
st.sv[i] = 0;
}
}
st.prev = value;
requestAnimationFrame(sync);
if (!st.raf) st.raf = requestAnimationFrame((n) => tickRef.current(n));
}, [value, reduced]);
const submit = async (e: FormEvent) => {
e.preventDefault();
const email = value.trim();
pill.press(0.9);
requestAnimationFrame(() => pill.release());
if (!/^[^\s@]+@[^\s@.]+(?:\.[^\s@.]+)+$/.test(email)) {
pill.flick(0.9);
setStatus({ tone: "err", text: email ? "That doesn't look like an email address." : "Type your email address first." });
inputRef.current?.focus();
return;
}
if (!onSubmit) {
setStatus({ tone: "note", text: "Looks good. Connect onSubmit to save it." });
return;
}
setBusy(true);
try {
await onSubmit(email);
if (!alive.current) return;
// The address hops out of the field, then the field clears.
const st = sim.current;
st.vy = st.vy.map((_, i) => -(320 + (i % 5) * 40));
setHopping(true);
if (!st.raf) st.raf = requestAnimationFrame((n) => tickRef.current(n));
hop.current = window.setTimeout(() => {
if (!alive.current) return;
setHopping(false);
setValue("");
setStatus({ tone: "ok", text: success });
}, reduced ? 0 : 320);
} catch {
if (alive.current) setStatus({ tone: "err", text: "That didn't go through. Try again in a moment." });
} finally {
if (alive.current) setBusy(false);
}
};
const field = "h-full w-full min-w-0 bg-transparent pl-6 pr-2 text-[17px] outline-none";
const type: CSSProperties = { fontFamily: SANS, fontWeight: 500, letterSpacing: "0.005em" };
return (
<form onSubmit={submit} noValidate className={"tm-scope flex w-full max-w-[560px] flex-col gap-3 " + className}>
<style>{FOCUS_CSS}</style>
<label htmlFor={id} className="text-[12px] uppercase" style={{ fontFamily: MONO, letterSpacing: "0.12em", color: DOUGH.ink3 }}>
{label}
</label>
<div
ref={pillRef}
className="flex h-[64px] items-center gap-2 rounded-full p-1.5 focus-within:[outline:2.5px_solid_#17130f] focus-within:[outline-offset:3px]"
style={{ background: DOUGH.surface, boxShadow: softShadow(), border: `1px solid ${status?.tone === "err" ? DOUGH.gumDeep : DOUGH.line}` }}
>
<div className="relative h-full min-w-0 flex-1 overflow-hidden rounded-full">
<input
ref={inputRef}
id={id}
type="email"
maxLength={254}
name="email"
autoComplete="email"
inputMode="email"
spellCheck={false}
placeholder={placeholder}
value={value}
onChange={(e) => {
setValue(e.target.value);
if (status?.tone !== "ok") setStatus(null);
}}
onScroll={sync}
onKeyUp={sync}
onSelect={sync}
aria-invalid={status?.tone === "err" || undefined}
aria-describedby={id + "-status"}
className={field + " focus-visible:!outline-none placeholder:text-[rgba(23,19,15,0.36)]"}
style={{ ...type, color: "transparent", caretColor: DOUGH.solid }}
/>
{/* The letters you see: a copy of the value, each letter on its own spring. */}
<span aria-hidden className="pointer-events-none absolute inset-0 flex items-center overflow-hidden pl-6 pr-2 text-[17px]" style={{ ...type, color: DOUGH.solid, opacity: hopping ? 0 : 1, transition: hopping ? "opacity 300ms ease" : "none" }}>
<span ref={trackRef} className="inline-flex whitespace-pre" style={{ transition: "none" }}>
{[...value].map((ch, i) => (
<span key={i} ref={(el) => void (charRefs.current[i] = el)} className="inline-block" style={{ transformOrigin: "50% 85%", transition: "none" }}>
{ch}
</span>
))}
</span>
</span>
</div>
<SquashPressButton type="submit" variant="gum" size="md" disabled={busy}>
{busy ? "Joining" : button}
</SquashPressButton>
</div>
<p id={id + "-status"} role="status" aria-live="polite" className="m-0 min-h-[20px] text-[14px]" style={{ color: status?.tone === "err" ? DOUGH.gumDeep : DOUGH.ink2 }}>
{status?.text ?? ""}
</p>
</form>
);
}
About this pattern
A waitlist field where typing feels like dropping something soft. Each letter falls into the field from a little above, stretched as it falls and squashed as it lands, its area kept, then settles; the native input is still underneath doing the real work (the caret, selection, paste, autofill, the keyboard on phones), only its drawing is replaced, and the drawn letters follow it when it scrolls sideways. Submit and the pill squashes under the button; a bad address makes it wobble and says why. Given onSubmit, a good address hops out of the field and a thank-you appears; without it, the field says the address looks fine but nothing saved it.
Curator’s note
The Tactile Maximalism kit's form (slot 11), cut from tactile-maximalism-site. Free: a spring per letter over a native input, and the kit's squash button; it only claims success when given onSubmit.
Pairs well with
Matched on shared tags and category — the entries most likely to be used alongside this one.
Chrome Rim Input
patternsAn email field in a pill with a chrome rim: each keystroke sends two ripples of light racing round the rim from the caret, a light laps it while sending, and success pours the pill into a round chrome drop with a check.
Deploy Log Input
patternsAn email field that answers with a streaming log: on submit, short lines stream under it with spinners turning into checks while a beam of light runs along its top edge, and the last line waits for your handler.
Aurora Glow Input
patternsA waitlist field that lights like the sky: focus raises a two-hue bloom and lights the rim, every keystroke sends a rose ripple from the caret, and joining sweeps the whole palette once.