Skip to content

Asterisk Submit Input

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.

FreeHolo Glossminimal
Preview
Enteryour mail

Props

No runtime dependencies
"use client";

import { useId, useRef, useState, type CSSProperties, type FormEvent } from "react";

/* ============================================================================
 * SHARED PIECES
 * ========================================================================== */

/** The kit's face. Urbanist is set at 300 for the quiet line, 700 for the loud one. */
export const HG_FONT = 'var(--font-urbanist, "Urbanist"), "Outfit", ui-sans-serif, system-ui, sans-serif';

/** The kit's palette. Ground and ink do the work; the colour lives inside the glass. */
export const HG = {
  ground: "#eef0f5",
  paper: "#f7f8fb",
  ink: "#15161b",
  inkSoft: "rgba(21,22,27,0.62)",
  inkMute: "rgba(21,22,27,0.42)",
  line: "rgba(21,22,27,0.10)",
  night: "#0d0e11",
  cobalt: "#2b46ff",
  violet: "#7b5cff",
  pink: "#ff6fb5",
  coral: "#ff4a4a",
  orange: "#ff8a1f",
} as const;

/** The eight-spoke asterisk the kit uses as caret, bullet and spinner. */
export function HgAsterisk({ className, style, stroke = 1.6 }: { className?: string; style?: CSSProperties; stroke?: number }) {
  return (
    <svg viewBox="0 0 24 24" aria-hidden className={className} style={style} fill="none" stroke="currentColor" strokeWidth={stroke} strokeLinecap="round">
      <path d="M12 3v18M3 12h18M5.6 5.6l12.8 12.8M18.4 5.6 5.6 18.4" />
    </svg>
  );
}

/* ============================================================================
 * NAV + ASTERISK HEADLINE
 * ========================================================================== */

const HG_NAV_CSS = `
.hg-pill{position:relative;overflow:hidden;isolation:isolate;transition:color .45s cubic-bezier(.2,.7,.2,1),border-color .45s,background-color .45s}
.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)}
.hg-pill:hover::before,.hg-pill:focus-visible::before{transform:none}
.hg-pill:hover,.hg-pill:focus-visible{color:var(--hg-pill-on)}
.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}
.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}
.hg-pill:focus-visible{outline:2px solid var(--hg-ring) !important;outline-offset:3px}
@keyframes hg-spin{to{transform:rotate(360deg)}}
@media (prefers-reduced-motion: reduce){.hg-pill::before,.hg-pill .hg-pill-star{transition:none}.hg-pill:hover .hg-pill-star{animation:none}}
`;

/* ============================================================================
 * "ENTER YOUR MAIL": ribbons, hanging cards, the form
 * ========================================================================== */

/** Linear-time email check: one @, a dot in the domain, no spaces. */
export function hgIsEmail(v: string): boolean {
  if (v.length > 254) return false;
  const at = v.indexOf("@");
  if (at < 1 || at !== v.lastIndexOf("@")) return false;
  const domain = v.slice(at + 1);
  const dot = domain.lastIndexOf(".");
  return dot > 0 && dot < domain.length - 2 && !/\s/.test(v);
}

export type HgSubmitState = "idle" | "sending" | "done" | "error";

const HG_INPUT_CSS = `
.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)}
.hg-in-star{transition:transform .5s cubic-bezier(.2,.7,.2,1),color .3s}
.hg-in-star[data-busy] svg{animation:hg-spin 1s linear infinite}
.hg-in-btn{transition:transform .25s cubic-bezier(.2,.7,.2,1),background-color .3s}
.hg-in-btn:hover{transform:translateY(-1px)}
.hg-in-btn:active{transform:translateY(1px) scale(.98)}
.hg-in-btn:focus-visible{outline:2px solid var(--hg-focus) !important;outline-offset:3px}
@media (prefers-reduced-motion: reduce){.hg-in-star,.hg-in-btn{transition:none}.hg-in-star[data-busy] svg{animation:none}}
`;

/**
 * The input row: a pill field, an asterisk that turns a step per keystroke
 * and spins while sending, and a black Submit pill. A real form. Without an
 * `onSubmit` it says plainly that nothing was sent.
 */
export function AsteriskSubmitInput({
  placeholder = "you@studio.com",
  button = "Submit",
  onSubmit,
  doneText = "You're on the list.",
  className,
  style,
}: {
  placeholder?: string;
  button?: string;
  /** Called with a valid address. Resolve to show done; throw to show the error. */
  onSubmit?: (email: string) => Promise<void> | void;
  doneText?: string;
  className?: string;
  style?: CSSProperties;
}) {
  const [value, setValue] = useState("");
  const [state, setState] = useState<HgSubmitState>("idle");
  const [msg, setMsg] = useState("");
  const turns = useRef(0);
  const star = useRef<HTMLSpanElement>(null);
  const id = useId();
  const submit = async (e: FormEvent) => {
    e.preventDefault();
    const v = value.trim();
    if (!hgIsEmail(v)) {
      setState("error");
      setMsg("That doesn't look like an email address.");
      return;
    }
    if (!onSubmit) {
      setState("idle");
      setMsg("Preview only: nothing was sent. Pass onSubmit to collect addresses.");
      return;
    }
    setState("sending");
    setMsg("");
    try {
      await onSubmit(v);
      setState("done");
      setMsg(doneText);
    } catch {
      setState("error");
      setMsg("Couldn't add you just now. Try again in a moment.");
    }
  };
  return (
    <form
      onSubmit={submit}
      noValidate
      className={"flex flex-col gap-2 " + (className ?? "")}
      style={{ fontFamily: HG_FONT, "--hg-focus": HG.cobalt, ...style } as CSSProperties}
    >
      <style>{HG_NAV_CSS + HG_INPUT_CSS}</style>
      <div className="flex items-center gap-[1.4em]">
        <label htmlFor={id} className="sr-only">
          Email address
        </label>
        <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]">
          <input
            id={id}
            type="email"
            inputMode="email"
            autoComplete="email"
            maxLength={254}
            required
            value={value}
            disabled={state === "sending"}
            aria-invalid={state === "error"}
            aria-describedby={id + "-msg"}
            placeholder={placeholder}
            onChange={(e) => {
              setValue(e.target.value);
              turns.current += 1;
              if (star.current) star.current.style.transform = "rotate(" + turns.current * 22.5 + "deg)";
              if (state !== "sending") {
                setState("idle");
                setMsg("");
              }
            }}
            className="w-full min-w-0 bg-transparent text-[0.95em] font-medium text-[#15161b] outline-none placeholder:text-[#15161b]/35"
            style={{ outline: "none" }}
          />
        </div>
        <span ref={star} aria-hidden className="hg-in-star inline-block text-[#15161b]/40" data-busy={state === "sending" ? "" : undefined}>
          <HgAsterisk className="h-[1.3em] w-[1.3em]" stroke={1.4} />
        </span>
        <button
          type="submit"
          disabled={state === "sending"}
          className="hg-in-btn h-[3em] shrink-0 rounded-full px-[1.9em] text-[0.88em] font-medium disabled:opacity-60"
          style={value.trim() ? { background: "#0c0c0e", color: "#fff" } : { background: "rgba(21,22,27,0.14)", color: "rgba(21,22,27,0.5)" }}
        >
          {state === "sending" ? "Sending…" : state === "done" ? "Added ✓" : button}
        </button>
      </div>
      <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 }}>
        {msg}
      </p>
    </form>
  );
}

Free, yours to ship. Pro adds the heroes, WebGL backgrounds and complete style kits of Holo Gloss, from $6/month (launch price until 31 Oct).

Notes

About this component

A small sign-up control with a tell. The field is a frosted pill with a cobalt focus ring. Beside it an asterisk turns a sixteenth of a turn with every keystroke, as if the form were winding up, and spins continuously while the request is in flight. The black Submit pill lifts a pixel on hover and presses on click. Underneath, one status line speaks for the form: it explains an invalid address, says Added when your onSubmit resolves, or apologises if it throws. If you have not wired onSubmit yet, it says so plainly, so a preview never claims to have collected an address it dropped.

Curator’s note

Validation is linear time (no backtracking regex on user input), the async handler's promise is the single source of the button's state, and without a handler the status line says nothing was sent instead of faking success.

Related

Pairs well with

Matched on shared tags and category — the entries most likely to be used alongside this one.

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

forminputemail

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.

inputemailform

A liquid-glass input whose rim notices the pointer before focus, thickens outward from where you click, ripples from the caret with every keystroke and clouds its frost on an error.

inputformtext field