{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"asterisk-submit-input","type":"registry:component","title":"Asterisk Submit Input","description":"An email field as a pill, an asterisk that turns one step per keystroke and spins while sending, and a black Submit pill. A real form with validation and an honest status line.","dependencies":[],"registryDependencies":[],"files":[{"path":"components/viberdy/asterisk-submit-input.tsx","content":"\"use client\";\n\nimport { useId, useRef, useState, type CSSProperties, type FormEvent } from \"react\";\n\n/* ============================================================================\n * SHARED PIECES\n * ========================================================================== */\n\n/** The kit's face. Urbanist is set at 300 for the quiet line, 700 for the loud one. */\nexport const HG_FONT = 'var(--font-urbanist, \"Urbanist\"), \"Outfit\", ui-sans-serif, system-ui, sans-serif';\n\n/** The kit's palette. Ground and ink do the work; the colour lives inside the glass. */\nexport const HG = {\n  ground: \"#eef0f5\",\n  paper: \"#f7f8fb\",\n  ink: \"#15161b\",\n  inkSoft: \"rgba(21,22,27,0.62)\",\n  inkMute: \"rgba(21,22,27,0.42)\",\n  line: \"rgba(21,22,27,0.10)\",\n  night: \"#0d0e11\",\n  cobalt: \"#2b46ff\",\n  violet: \"#7b5cff\",\n  pink: \"#ff6fb5\",\n  coral: \"#ff4a4a\",\n  orange: \"#ff8a1f\",\n} as const;\n\n/** The eight-spoke asterisk the kit uses as caret, bullet and spinner. */\nexport function HgAsterisk({ className, style, stroke = 1.6 }: { className?: string; style?: CSSProperties; stroke?: number }) {\n  return (\n    <svg viewBox=\"0 0 24 24\" aria-hidden className={className} style={style} fill=\"none\" stroke=\"currentColor\" strokeWidth={stroke} strokeLinecap=\"round\">\n      <path d=\"M12 3v18M3 12h18M5.6 5.6l12.8 12.8M18.4 5.6 5.6 18.4\" />\n    </svg>\n  );\n}\n\n/* ============================================================================\n * NAV + ASTERISK HEADLINE\n * ========================================================================== */\n\nconst HG_NAV_CSS = `\n.hg-pill{position:relative;overflow:hidden;isolation:isolate;transition:color .45s cubic-bezier(.2,.7,.2,1),border-color .45s,background-color .45s}\n.hg-pill::before{content:\"\";position:absolute;inset:0;z-index:-1;border-radius:inherit;background:var(--hg-pill-fill);transform:translateY(102%);transition:transform .5s cubic-bezier(.2,.7,.2,1)}\n.hg-pill:hover::before,.hg-pill:focus-visible::before{transform:none}\n.hg-pill:hover,.hg-pill:focus-visible{color:var(--hg-pill-on)}\n.hg-pill .hg-pill-star{width:0;opacity:0;margin-left:0;transition:width .45s cubic-bezier(.2,.7,.2,1),opacity .3s,margin .45s}\n.hg-pill:hover .hg-pill-star,.hg-pill:focus-visible .hg-pill-star{width:.8em;opacity:1;margin-left:.45em;animation:hg-spin 2.4s linear infinite}\n.hg-pill:focus-visible{outline:2px solid var(--hg-ring) !important;outline-offset:3px}\n@keyframes hg-spin{to{transform:rotate(360deg)}}\n@media (prefers-reduced-motion: reduce){.hg-pill::before,.hg-pill .hg-pill-star{transition:none}.hg-pill:hover .hg-pill-star{animation:none}}\n`;\n\n/* ============================================================================\n * \"ENTER YOUR MAIL\": ribbons, hanging cards, the form\n * ========================================================================== */\n\n/** Linear-time email check: one @, a dot in the domain, no spaces. */\nexport function hgIsEmail(v: string): boolean {\n  if (v.length > 254) return false;\n  const at = v.indexOf(\"@\");\n  if (at < 1 || at !== v.lastIndexOf(\"@\")) return false;\n  const domain = v.slice(at + 1);\n  const dot = domain.lastIndexOf(\".\");\n  return dot > 0 && dot < domain.length - 2 && !/\\s/.test(v);\n}\n\nexport type HgSubmitState = \"idle\" | \"sending\" | \"done\" | \"error\";\n\nconst HG_INPUT_CSS = `\n.hg-in-field:focus-within{border-color:var(--hg-focus) !important;box-shadow:0 0 0 4px color-mix(in srgb, var(--hg-focus) 16%, transparent)}\n.hg-in-star{transition:transform .5s cubic-bezier(.2,.7,.2,1),color .3s}\n.hg-in-star[data-busy] svg{animation:hg-spin 1s linear infinite}\n.hg-in-btn{transition:transform .25s cubic-bezier(.2,.7,.2,1),background-color .3s}\n.hg-in-btn:hover{transform:translateY(-1px)}\n.hg-in-btn:active{transform:translateY(1px) scale(.98)}\n.hg-in-btn:focus-visible{outline:2px solid var(--hg-focus) !important;outline-offset:3px}\n@media (prefers-reduced-motion: reduce){.hg-in-star,.hg-in-btn{transition:none}.hg-in-star[data-busy] svg{animation:none}}\n`;\n\n/**\n * The input row: a pill field, an asterisk that turns a step per keystroke\n * and spins while sending, and a black Submit pill. A real form. Without an\n * `onSubmit` it says plainly that nothing was sent.\n */\nexport function AsteriskSubmitInput({\n  placeholder = \"you@studio.com\",\n  button = \"Submit\",\n  onSubmit,\n  doneText = \"You're on the list.\",\n  className,\n  style,\n}: {\n  placeholder?: string;\n  button?: string;\n  /** Called with a valid address. Resolve to show done; throw to show the error. */\n  onSubmit?: (email: string) => Promise<void> | void;\n  doneText?: string;\n  className?: string;\n  style?: CSSProperties;\n}) {\n  const [value, setValue] = useState(\"\");\n  const [state, setState] = useState<HgSubmitState>(\"idle\");\n  const [msg, setMsg] = useState(\"\");\n  const turns = useRef(0);\n  const star = useRef<HTMLSpanElement>(null);\n  const id = useId();\n  const submit = async (e: FormEvent) => {\n    e.preventDefault();\n    const v = value.trim();\n    if (!hgIsEmail(v)) {\n      setState(\"error\");\n      setMsg(\"That doesn't look like an email address.\");\n      return;\n    }\n    if (!onSubmit) {\n      setState(\"idle\");\n      setMsg(\"Preview only: nothing was sent. Pass onSubmit to collect addresses.\");\n      return;\n    }\n    setState(\"sending\");\n    setMsg(\"\");\n    try {\n      await onSubmit(v);\n      setState(\"done\");\n      setMsg(doneText);\n    } catch {\n      setState(\"error\");\n      setMsg(\"Couldn't add you just now. Try again in a moment.\");\n    }\n  };\n  return (\n    <form\n      onSubmit={submit}\n      noValidate\n      className={\"flex flex-col gap-2 \" + (className ?? \"\")}\n      style={{ fontFamily: HG_FONT, \"--hg-focus\": HG.cobalt, ...style } as CSSProperties}\n    >\n      <style>{HG_NAV_CSS + HG_INPUT_CSS}</style>\n      <div className=\"flex items-center gap-[1.4em]\">\n        <label htmlFor={id} className=\"sr-only\">\n          Email address\n        </label>\n        <div className=\"hg-in-field flex h-[3.2em] min-w-0 flex-1 items-center rounded-full border border-[#15161b]/12 bg-white/70 px-[1.5em] transition-[border-color,box-shadow]\">\n          <input\n            id={id}\n            type=\"email\"\n            inputMode=\"email\"\n            autoComplete=\"email\"\n            maxLength={254}\n            required\n            value={value}\n            disabled={state === \"sending\"}\n            aria-invalid={state === \"error\"}\n            aria-describedby={id + \"-msg\"}\n            placeholder={placeholder}\n            onChange={(e) => {\n              setValue(e.target.value);\n              turns.current += 1;\n              if (star.current) star.current.style.transform = \"rotate(\" + turns.current * 22.5 + \"deg)\";\n              if (state !== \"sending\") {\n                setState(\"idle\");\n                setMsg(\"\");\n              }\n            }}\n            className=\"w-full min-w-0 bg-transparent text-[0.95em] font-medium text-[#15161b] outline-none placeholder:text-[#15161b]/35\"\n            style={{ outline: \"none\" }}\n          />\n        </div>\n        <span ref={star} aria-hidden className=\"hg-in-star inline-block text-[#15161b]/40\" data-busy={state === \"sending\" ? \"\" : undefined}>\n          <HgAsterisk className=\"h-[1.3em] w-[1.3em]\" stroke={1.4} />\n        </span>\n        <button\n          type=\"submit\"\n          disabled={state === \"sending\"}\n          className=\"hg-in-btn h-[3em] shrink-0 rounded-full px-[1.9em] text-[0.88em] font-medium disabled:opacity-60\"\n          style={value.trim() ? { background: \"#0c0c0e\", color: \"#fff\" } : { background: \"rgba(21,22,27,0.14)\", color: \"rgba(21,22,27,0.5)\" }}\n        >\n          {state === \"sending\" ? \"Sending…\" : state === \"done\" ? \"Added ✓\" : button}\n        </button>\n      </div>\n      <p id={id + \"-msg\"} role=\"status\" aria-live=\"polite\" className=\"min-h-[1.3em] px-[1.5em] text-[0.78em]\" style={{ color: state === \"error\" ? \"#d0243a\" : HG.inkSoft }}>\n        {msg}\n      </p>\n    </form>\n  );\n}\n","type":"registry:component","target":"components/viberdy/asterisk-submit-input.tsx"}],"meta":{"tier":"free","tags":["input","email","form","newsletter","submit button","validation","asterisk"],"href":"https://viberdy.dev/library/asterisk-submit-input","aiPrompt":"Build a React + Tailwind email capture row, `AsteriskSubmitInput`, in one \"use client\" file with no dependencies. It is a real `<form>`: a pill-shaped email field, a small eight-spoke asterisk that turns a step on every keystroke and spins while sending, and a Submit pill that is grey until there is something to send and then turns black. Under the row sits a status line that reports validation, success and failure. Sizing is in `em`, so the host scales the whole row by setting a font size on the wrapper.\n\nShared pieces in the same file: `HG_FONT` (`var(--font-urbanist, \"Urbanist\"), \"Outfit\", ui-sans-serif, system-ui, sans-serif`), the `HG` palette (ink #15161b, inkSoft rgba(21,22,27,0.62), cobalt #2b46ff, plus ground #eef0f5, paper #f7f8fb, night #0d0e11, violet #7b5cff, pink #ff6fb5, coral #ff4a4a, orange #ff8a1f), and `HgAsterisk`, a 24 by 24 aria-hidden SVG with `stroke=\"currentColor\"`, round caps, default stroke 1.6, and one path `M12 3v18M3 12h18M5.6 5.6l12.8 12.8M18.4 5.6 5.6 18.4`.\n\nValidation: export `hgIsEmail(v)`, a linear-time check with no regex backtracking risk. Reject over 254 characters; require exactly one \"@\" that is not at index 0 (`indexOf` equals `lastIndexOf`); the domain must contain a dot that is not first and leaves at least two characters after it; no whitespace anywhere. Export the type `HgSubmitState = \"idle\" | \"sending\" | \"done\" | \"error\"`.\n\nLayout. The form is `flex flex-col gap-2`, `noValidate`, font-family HG_FONT, with the custom property `--hg-focus` set to cobalt. The top row is `flex items-center gap-[1.4em]` and holds, in order:\n- A visually hidden `<label>` (\"Email address\", `sr-only`) tied to the input with `useId`.\n- The field shell `.hg-in-field`: `flex h-[3.2em] min-w-0 flex-1 items-center rounded-full border border-[#15161b]/12 bg-white/70 px-[1.5em]`. Inside, a transparent input, `w-full min-w-0`, text 0.95em, weight 500, colour #15161b, placeholder #15161b at 35%, outline none. It is `type=\"email\"`, `inputMode=\"email\"`, `autoComplete=\"email\"`, `maxLength={254}`, `required`, disabled while sending, `aria-invalid` when in the error state and `aria-describedby` pointing at the status line.\n- The asterisk: a span `inline-block` with `text-[#15161b]/40` wrapping an `HgAsterisk` at 1.3em by 1.3em, stroke 1.4, aria-hidden.\n- The submit button: `h-[3em] shrink-0 rounded-full px-[1.9em] text-[0.88em] font-medium`, `disabled:opacity-60`. While the trimmed field is empty it is a quiet grey pill (background rgba(21,22,27,0.14), text rgba(21,22,27,0.5)); as soon as the field has text it turns black (#0c0c0e) with white text, the background change transitioning .3s. Its label is the `button` prop, \"Sending\" with an ellipsis while sending, and \"Added\" with a check mark once done.\nBelow the row is a `<p role=\"status\" aria-live=\"polite\">` with `min-h-[1.3em]` (so nothing jumps), `px-[1.5em]`, 0.78em text, coloured #d0243a for errors and inkSoft otherwise.\n\nBehaviour. Each change event stores the value, increments a turn counter in a ref and writes `rotate(turns * 22.5deg)` directly onto the asterisk span's inline transform (so the asterisk advances a sixteenth of a turn per key without re-rendering); it also clears any message and resets a non-sending state to idle. On submit: prevent default, trim, and if `hgIsEmail` fails show \"That doesn't look like an email address.\" in the error state. If no `onSubmit` was passed, stay idle and show \"Preview only: nothing was sent. Pass onSubmit to collect addresses.\" (the component must never pretend to have collected an address). Otherwise set sending, await `onSubmit(email)`, then show done with `doneText`; if it throws, show the error \"Couldn't add you just now. Try again in a moment.\"\n\nMotion, in an inline `<style>`: the field gets, on `:focus-within`, a cobalt border (`var(--hg-focus)`, important) and a 4px ring made with `color-mix(in srgb, var(--hg-focus) 16%, transparent)`, transitioning border-color and box-shadow. The asterisk transitions `transform .5s cubic-bezier(.2,.7,.2,1)` and colour .3s; while `data-busy` is set (sending) its SVG runs `hg-spin` (360 degrees, 1s linear infinite). The button transitions `transform .25s cubic-bezier(.2,.7,.2,1)` and background .3s: hover lifts it `translateY(-1px)`, active presses it `translateY(1px) scale(.98)`, and `:focus-visible` shows a 2px cobalt outline (important) with 3px offset. The same shared nav CSS block (the `.hg-pill` rules and the `hg-spin` keyframes) is included because the spin keyframes live there.\n\nReduced motion: transitions on the asterisk and button are removed and the busy spin is switched off.\n\nProps: `placeholder` (\"you@studio.com\"), `button` (\"Submit\"), `onSubmit` (optional `(email: string) => Promise<void> | void`; resolve for done, throw for error), `doneText` (\"You're on the list.\"), `className`, `style`.","license":"https://viberdy.dev/license"}}