Skip to content

Aurora Glow Input

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

DOM + CSS0 deps1 file · 17 KB TSXreduced-motion aware

FreeAuroraneonglass
Preview

Props

Fail demo
No runtime dependencies
"use client";

import { useId, useRef, useState, useSyncExternalStore } from "react";
import type { CSSProperties, FormEvent, KeyboardEvent as ReactKeyboardEvent, ReactNode } from "react";

/**
 * AuroraGlowInput — a waitlist field that lights like the sky.
 *
 * A hairline pill at rest; focus raises a soft two-hue bloom (320ms) and
 * lights the rim, and every keystroke sends a small rose ripple from the
 * caret (260ms). The address goes to `onJoin`, which is required: the field
 * never claims a sign-up on its own. On success the whole palette sweeps
 * once through the bloom (a 600ms borealis flash).
 *
 * Part of the Aurora kit: a near-black night (#050608) lit by curtains of
 * aurora in mint #7cf5c4 and sky #38bdf8, a violet #a78bfa blend, a rose
 * #f472b6 crown and an indigo #5b6cc4 fringe, all on one 60s clock; Bricolage
 * Grotesque through var(--font-bricolage) for lit display type 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. For the display face, load Bricolage Grotesque with next/font
 * (variable: "--font-bricolage") on a parent; elsewhere, load it with a
 * Google Fonts link or @fontsource and set --font-bricolage yourself.
 * Needs Tailwind v4 (on v3.4, add the @tailwindcss/container-queries plugin).
 */

const SANS = 'var(--font-sans, "Geist", "Inter", ui-sans-serif, system-ui, sans-serif)';

const EASE = "cubic-bezier(0.22, 1, 0.36, 1)";

const HEX = /^#[0-9a-fA-F]{6}$/;

/** The night the kit is set in. */
const SKY = {
  ground: "#050608",
  panel: "#0a0d12",
  raised: "#0f141a",
  ink: "rgba(236,244,248,0.94)",
  ink2: "rgba(236,244,248,0.64)",
  ink3: "rgba(236,244,248,0.42)",
  line: "rgba(236,244,248,0.08)",
  line2: "rgba(236,244,248,0.16)",
  shadow: "0 40px 100px -30px rgba(0,0,0,0.7)",
} as const;

/**
 * The sky's colours by altitude: the core low in the curtain (two hues it
 * drifts between on the aurora clock), a violet blend above it, a rose crown
 * seen only in a strong display, and an indigo fringe under the lower border.
 */
export type AuPalette = { core: string; core2: string; blend: string; high: string; fringe: string };

export const AU_PALETTES = {
  boreal: { core: "#7cf5c4", core2: "#38bdf8", blend: "#a78bfa", high: "#f472b6", fringe: "#5b6cc4" },
  verdant: { core: "#6ee7a0", core2: "#7cf5c4", blend: "#8fb4f0", high: "#e879b9", fringe: "#5b6cc4" },
  violet: { core: "#a78bfa", core2: "#38bdf8", blend: "#c4a6fb", high: "#f472b6", fringe: "#4c5bb8" },
} as const satisfies Record<string, AuPalette>;

export type AuPaletteName = keyof typeof AU_PALETTES;

function own<T extends object>(map: T, key: string): key is Extract<keyof T, string> {
  return Object.prototype.hasOwnProperty.call(map, key);
}

/** A named palette, or boreal. */
function paletteOf(name: string): AuPalette {
  return own(AU_PALETTES, name) ? AU_PALETTES[name] : AU_PALETTES.boreal;
}

function rgbOf(hex: string): [number, number, number] {
  const n = parseInt((HEX.test(hex) ? hex : "#7cf5c4").slice(1), 16);
  return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}

function rgba(hex: string, a: number): string {
  const [r, g, b] = rgbOf(hex);
  return `rgba(${r},${g},${b},${a})`;
}

/**
 * The aurora clock: every lit part of the page reads one 60s cycle, so a
 * button, a rim and the sky drift together instead of breathing out of step.
 * The phase is the page's own timeline (performance.now), shared by
 * everything on it.
 */
const CLOCK_S = 60;

/**
 * A ref callback that starts an element's CSS animation where the clock
 * already is, so parts that mount at different moments stay in step.
 */
function syncClock(periodS: number) {
  return (el: HTMLElement | null) => {
    if (el) el.style.animationDelay = `${(-(performance.now() / 1000) % periodS).toFixed(3)}s`;
  };
}

/** 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,
  );
}

// Each part carries only the CSS it needs, so any one of them works on its own.
// Nothing is interpolated into these sheets; colours arrive as CSS variables.
// revert-layer keeps an element's own rounded corners when a host page's focus rule flattens them.
const FOCUS_CSS = ".au-scope :focus-visible,.au-scope:focus-visible{outline:2px solid var(--au-ring,#7cf5c4);outline-offset:3px;border-radius:revert-layer}";

/** The palette as CSS variables, for the parts drawn in CSS. */
function paletteVars(p: AuPalette): CSSProperties {
  return { "--au-core": p.core, "--au-core2": p.core2, "--au-blend": p.blend, "--au-high": p.high, "--au-ring": p.core } as CSSProperties;
}

// A registered angle, so the under-glow turns on the compositor without a render per frame.
const GLOW_BTN_CSS =
  "@property --au-a{syntax:'<angle>';inherits:false;initial-value:0deg}" +
  "@keyframes au-turn{to{--au-a:360deg}}" +
  "@keyframes au-ring{from{opacity:.9;transform:scale(1)}to{opacity:0;transform:scale(1.18,1.5)}}" +
  ".au-glow{inset:-6px -8px -12px;opacity:.55;animation:au-turn 60s linear infinite}" +
  ".au-btn:hover .au-glow,.au-btn:focus-visible .au-glow{inset:-12px -14px -18px;opacity:.85}" +
  ".au-q .au-glow{opacity:0}.au-q:hover .au-glow,.au-q:focus-visible .au-glow{opacity:.4}" +
  ".au-btn:active{transform:translateY(1px) scale(.985)}" +
  "@media (prefers-reduced-motion: reduce){.au-glow{animation:none}.au-ring{animation:none!important;opacity:0}}";

export type AuroraGlowButtonProps = {
  children: ReactNode;
  /** Renders a link when given, else a button. */
  href?: string;
  onClick?: () => void;
  type?: "button" | "submit";
  /** "solid" carries the sky under it; "quiet" is a hairline pill whose light wakes on hover. */
  variant?: "solid" | "quiet";
  size?: "md" | "lg";
  palette?: AuPaletteName;
  disabled?: boolean;
  className?: string;
};

/**
 * A pill with a sliver of the sky under it. The under-glow is a slow turn
 * through the palette on the page's 60s aurora clock, so every button on the
 * page glows in step with the sky; hover or focus lets it spread (220ms), and
 * a press (or Enter or Space) sends a ring of light out from the pill (380ms). The top edge
 * carries a thin lit rim. Renders a link when given an href, else a button.
 */
export function AuroraGlowButton({ children, href, onClick, type = "button", variant = "solid", size = "md", palette = "boreal", disabled, className }: AuroraGlowButtonProps) {
  const p = paletteOf(palette);
  const [ring, setRing] = useState(0);
  const solid = variant === "solid";
  const cls =
    "au-scope au-btn relative isolate inline-flex select-none items-center justify-center gap-2 whitespace-nowrap rounded-full font-medium transition-[transform,color,box-shadow] duration-200 disabled:cursor-not-allowed disabled:opacity-50 " +
    (size === "lg" ? "h-[52px] px-7 text-[15px]" : "h-11 px-5 text-[14px]") +
    (solid ? "" : " au-q") +
    (className ? " " + className : "");
  const style: CSSProperties = {
    ...paletteVars(p),
    color: SKY.ink,
    fontFamily: SANS,
    background: solid ? `linear-gradient(180deg, ${SKY.raised}, ${SKY.panel})` : "transparent",
    boxShadow: solid ? `inset 0 0 0 1px ${SKY.line2}, 0 10px 30px -12px rgba(0,0,0,0.8)` : `inset 0 0 0 1px ${SKY.line2}`,
  };
  const sheet = <style>{FOCUS_CSS + GLOW_BTN_CSS}</style>;
  const inner = (
    <>
      {/* The sky under the pill, turning on the aurora clock. */}
      <span
        ref={syncClock(CLOCK_S)}
        aria-hidden
        className="au-glow pointer-events-none absolute -z-10 rounded-full transition-[inset,opacity] duration-200"
        style={{
          background: "conic-gradient(from var(--au-a), var(--au-core), var(--au-core2), var(--au-blend), var(--au-core2), var(--au-core))",
          filter: "blur(14px)",
        }}
      />
      {/* A lit rim along the top edge. */}
      <span
        aria-hidden
        className="pointer-events-none absolute inset-0 rounded-full"
        style={{
          padding: 1,
          background: `linear-gradient(90deg, transparent 12%, ${rgba(p.core, solid ? 0.7 : 0.35)} 40%, ${rgba(p.core2, solid ? 0.6 : 0.3)} 62%, transparent 88%) top / 100% 1px no-repeat`,
        }}
      />
      {ring > 0 && (
        <span
          key={ring}
          aria-hidden
          className="au-ring pointer-events-none absolute inset-0 rounded-full"
          style={{ boxShadow: `0 0 0 1px ${rgba(p.core, 0.8)}, 0 0 24px ${rgba(p.core2, 0.5)}`, animation: `au-ring 380ms ${EASE} both` }}
        />
      )}
      <span className="relative">{children}</span>
    </>
  );
  const onDown = () => {
    if (!disabled) setRing((r) => r + 1);
  };
  const onKey = (e: ReactKeyboardEvent) => {
    if (!e.repeat && (e.key === "Enter" || e.key === " ")) onDown();
  };
  if (href !== undefined) {
    return (
      <>
        {sheet}
        <a href={safeHref(href)} onClick={onClick} onPointerDown={onDown} onKeyDown={onKey} className={cls} style={style}>
          {inner}
        </a>
      </>
    );
  }
  return (
    <>
      {sheet}
      <button type={type} onClick={onClick} onPointerDown={onDown} onKeyDown={onKey} disabled={disabled} className={cls} style={style}>
        {inner}
      </button>
    </>
  );
}

const INPUT_CSS =
  ".au-field .au-bloom{opacity:0;transition:opacity 320ms cubic-bezier(0.22,1,0.36,1)}" +
  ".au-field:focus-within .au-bloom{opacity:.55}" +
  ".au-field .au-lit{opacity:0;transition:opacity 320ms cubic-bezier(0.22,1,0.36,1)}" +
  ".au-field:focus-within .au-lit{opacity:1}" +
  ".au-field input:focus-visible{outline:none}" +
  "@media (prefers-reduced-motion: reduce){.au-field .au-bloom,.au-field .au-lit{transition:none}}";

export type AuroraGlowInputProps = {
  /** Called with the address. Resolve when it is received; throw to show `failed`. Required: the field never claims a sign-up on its own. */
  onJoin: (email: string) => Promise<void> | void;
  label?: string;
  placeholder?: string;
  cta?: string;
  done?: string;
  failed?: string;
  note?: string;
  palette?: AuPaletteName;
  className?: string;
};

/**
 * A waitlist field that lights like the sky. At rest it is a hairline pill;
 * focus raises a soft two-hue bloom around it (320ms) and lights its rim, and
 * every keystroke sends a small rose ripple out from the caret (260ms). The
 * address goes to `onJoin`, which is required; while it runs the button says
 * so, and when it resolves the whole palette sweeps once through the bloom
 * (a 600ms borealis flash) and the field settles into its thank-you.
 * A native email input with a label and a polite status line.
 */
export function AuroraGlowInput({
  onJoin,
  label = "Email address",
  placeholder = "you@company.com",
  cta = "Join the waitlist",
  done = "You are on the list. We will write before your first night.",
  failed = "That did not go through. Try again in a moment.",
  note,
  palette = "boreal",
  className,
}: AuroraGlowInputProps) {
  const p = paletteOf(palette);
  const reduced = useReducedMotion();
  const [email, setEmail] = useState("");
  const [state, setState] = useState<"idle" | "sending" | "done" | "error">("idle");
  const [hint, setHint] = useState("");
  const inputRef = useRef<HTMLInputElement>(null);
  const mirrorRef = useRef<HTMLSpanElement>(null);
  const rippleRef = useRef<HTMLSpanElement>(null);
  const bloomRef = useRef<HTMLSpanElement>(null);
  const uid = useId().replace(/[^a-zA-Z0-9]/g, "");

  // A ripple from the caret: measure the text before it in a hidden mirror of the field.
  const ripple = () => {
    const input = inputRef.current;
    const mirror = mirrorRef.current;
    const layer = rippleRef.current;
    if (!input || !mirror || !layer || reduced) return;
    mirror.textContent = input.value.slice(0, input.selectionStart ?? input.value.length) || "";
    const x = Math.min(input.clientWidth - 8, mirror.offsetWidth - input.scrollLeft) + 22;
    // Only numbers and the palette's own hex reach this style string; keep it that way if you extend it.
    const dot = document.createElement("span");
    dot.setAttribute("aria-hidden", "true");
    dot.style.cssText = `position:absolute;left:${x}px;top:50%;width:46px;height:46px;margin:-23px 0 0 -23px;border-radius:999px;pointer-events:none;background:radial-gradient(closest-side, ${rgba(p.high, 0.55)}, ${rgba(p.high, 0)});`;
    layer.appendChild(dot);
    const a = dot.animate([{ transform: "scale(0.25)", opacity: 0.9 }, { transform: "scale(1.5)", opacity: 0 }], { duration: 260, easing: EASE });
    a.onfinish = () => dot.remove();
  };

  const submit = async (e: FormEvent) => {
    e.preventDefault();
    if (state === "sending" || state === "done") return;
    if (!inputRef.current?.checkValidity() || !email.trim()) {
      setHint("Enter an email address.");
      inputRef.current?.focus();
      return;
    }
    setHint("");
    setState("sending");
    try {
      await onJoin(email.trim());
      setState("done");
      // The borealis flash: the whole palette sweeps once through the bloom.
      const b = bloomRef.current;
      if (b && !reduced) {
        b.animate(
          [
            { opacity: 0.2, backgroundPosition: "0% 50%" },
            { opacity: 0.95, backgroundPosition: "100% 50%", offset: 0.55 },
            { opacity: 0.45, backgroundPosition: "200% 50%" },
          ],
          { duration: 600, easing: EASE },
        );
      }
    } catch {
      setState("error");
    }
  };

  const doneNow = state === "done";
  return (
    <form onSubmit={submit} noValidate className={"au-scope @container w-full" + (className ? " " + className : "")} style={{ ...paletteVars(p), color: SKY.ink, fontFamily: SANS }}>
      <style>{FOCUS_CSS + INPUT_CSS}</style>
      <label htmlFor={`${uid}-email`} className="sr-only">
        {label}
      </label>
      <div className="flex flex-col gap-3 @md:flex-row @md:items-center">
        <div className="au-field relative isolate h-[54px] min-w-0 flex-1 rounded-full">
          {/* The bloom behind the field; on success it carries the borealis flash. */}
          <span
            ref={bloomRef}
            aria-hidden
            className="au-bloom pointer-events-none absolute -inset-1 -z-10 rounded-full"
            style={{ background: `linear-gradient(90deg, ${p.core2}, ${p.core}, ${p.blend}, ${p.high}, ${p.core2}, ${p.core})`, backgroundSize: "200% 100%", filter: "blur(16px)" }}
          />
          <span aria-hidden className="pointer-events-none absolute inset-0 rounded-full" style={{ background: SKY.panel, boxShadow: `inset 0 0 0 1px ${SKY.line2}` }} />
          {/* The lit rim on focus. */}
          <span
            aria-hidden
            className="au-lit pointer-events-none absolute inset-0 rounded-full"
            style={{
              padding: 1,
              background: `linear-gradient(90deg, ${p.core2}, ${p.core} 50%, ${p.blend})`,
              WebkitMask: "linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)",
              WebkitMaskComposite: "xor",
              mask: "linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)",
              maskComposite: "exclude",
            }}
          />
          <span ref={rippleRef} aria-hidden className="pointer-events-none absolute inset-0 overflow-hidden rounded-full" />
          {/* A hidden mirror of the text, to find the caret. */}
          <span ref={mirrorRef} aria-hidden className="invisible absolute left-0 top-0 whitespace-pre text-[15.5px]" />
          <input
            ref={inputRef}
            id={`${uid}-email`}
            type="email"
            required
            autoComplete="email"
            placeholder={placeholder}
            value={email}
            disabled={doneNow}
            onChange={(e) => {
              setEmail(e.target.value);
              if (state === "error") setState("idle");
              if (hint) setHint("");
            }}
            onKeyUp={(e: ReactKeyboardEvent<HTMLInputElement>) => {
              if (e.key.length === 1 || e.key === "Backspace") ripple();
            }}
            aria-describedby={`${uid}-msg`}
            aria-invalid={hint ? true : undefined}
            className="relative h-full w-full rounded-full bg-transparent px-[22px] text-[15.5px] placeholder:text-[rgba(236,244,248,0.38)] disabled:opacity-70"
            style={{ color: SKY.ink }}
          />
        </div>
        <AuroraGlowButton type="submit" size="lg" palette={palette} disabled={state === "sending" || doneNow}>
          {state === "sending" ? "Joining" : doneNow ? "Joined" : cta}
        </AuroraGlowButton>
      </div>
      <p id={`${uid}-msg`} role="status" aria-live="polite" className="m-0 mt-3 flex min-h-[20px] items-center gap-2 text-[13.5px]" style={{ color: state === "error" || hint ? "#f4a3b4" : doneNow ? SKY.ink : SKY.ink3 }}>
        {doneNow && <span aria-hidden className="h-1.5 w-1.5 rounded-full" style={{ background: p.core, boxShadow: `0 0 10px ${p.core}` }} />}
        {state === "error" ? failed : doneNow ? done : hint || note || ""}
      </p>
    </form>
  );
}

Free. Pro unlocks everything, from $27/month.

Details

About this pattern

An email field for a waitlist that answers every touch with light. At rest it is a hairline pill; focus raises a soft two-hue bloom around it and lights its rim in 320ms, and every keystroke sends a small rose ripple out from the caret, found by measuring the text before it in a hidden mirror of the field. The address goes to your onJoin handler, which is required: while it runs the button says so, a failure shows a message and keeps the address, and when it resolves the whole palette sweeps once through the bloom (a 600ms borealis flash) and the field settles into its thank-you. The button is the kit's glow button.

Curator’s note

The Aurora kit's form (slot 11), cut from aurora-site. Free: CSS focus-within states, a mirror span for the caret, WAAPI ripples and a required handler.

Pairs well with