{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "stencil-fill",
  "title": "Stencil Fill",
  "description": "A reusable pattern-masked input: the full template renders at rest as a muted stencil with literal separators already in place, each typed character inks its own cell, and a refused character prints faint, shakes, and falls away instead of vanishing silently.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/stencil-fill/component.tsx",
      "content": "\"use client\";\n\nimport {\n  useCallback,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n  type ChangeEvent,\n  type ClipboardEvent,\n  type KeyboardEvent,\n} from \"react\";\n\n// ---------------------------------------------------------------------------\n// StencilFill — a reusable pattern-masked input. The full template is\n// visible at rest as a muted stencil (e.g. \"XXXX-XXXX-XXXX\", literal\n// separators already in place); each typed character inks its own cell from\n// --ns-muted to --foreground. One native <input> (transparent text/caret,\n// positioned exactly over the decorative glyph grid — the same \"real control\n// drives, decorative layer displays\" split as card-number-emboss) owns\n// focus, value, label and autocomplete semantics, so the emitted value keeps\n// its separators. A rejected character prints in its own cell at 40% muted,\n// shakes on a spring, then falls away — the signature \"show what was\n// refused\" beat — while aria-describedby states the format contract before\n// the first keystroke, rather than auto-inserting separators as you type.\n//\n// This is the reusable MASK MECHANISM (license keys, IBANs, phone numbers,\n// ticket IDs — any 'X'-templated string), not a format-specific treatment:\n// card-number-emboss embosses one fixed payment layout, this stencils\n// whatever mask you hand it.\n// ---------------------------------------------------------------------------\n\nexport interface StencilFillProps {\n  /** Template string: 'X' = editable cell, any other character = literal separator (auto-skipped, never typed). */\n  mask: string;\n  /** Accessible name for the underlying input. */\n  label: string;\n  /** Overrides the auto-derived aria-describedby copy. */\n  formatDescription?: string;\n  /** Per-character acceptance check. Defaults to A-Z / 0-9. */\n  validate?: (char: string) => boolean;\n  /** Normalizes an accepted character before it's inked. Defaults to uppercase. */\n  transform?: (char: string) => string;\n  /** Builds the polite live-region message for a refused character. */\n  rejectMessage?: (char: string) => string;\n  name?: string;\n  autoComplete?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n  className?: string;\n}\n\ninterface MaskToken {\n  kind: \"slot\" | \"sep\";\n  char: string;\n}\n\nfunction parseMask(mask: string): MaskToken[] {\n  return mask.split(\"\").map((char) => (char === \"X\" ? { kind: \"slot\", char } : { kind: \"sep\", char }));\n}\n\nfunction deriveFormatDescription(mask: string): string {\n  const groups = mask.match(/X+/g) ?? [];\n  const seps = Array.from(new Set(mask.replace(/X/g, \"\").split(\"\").filter(Boolean)));\n  const firstGroupLen = groups[0]?.length ?? 0;\n  if (groups.length > 1 && groups.every((g) => g.length === firstGroupLen) && seps.length === 1) {\n    return `Format: ${groups.length} groups of ${firstGroupLen} characters, separated by ${seps[0]}`;\n  }\n  if (groups.length === 1 && seps.length === 0) {\n    return `Format: ${firstGroupLen} characters`;\n  }\n  return `Format: ${mask}`;\n}\n\nfunction defaultValidate(char: string): boolean {\n  return /^[A-Za-z0-9]$/.test(char);\n}\n\nfunction defaultTransform(char: string): string {\n  return char.toUpperCase();\n}\n\nfunction defaultRejectMessage(char: string): string {\n  return /^[a-zA-Z]$/.test(char) ? `Letter ${char.toUpperCase()} not allowed` : `Character \"${char}\" not allowed`;\n}\n\nfunction buildValue(tokens: MaskToken[], typed: string[]): string {\n  let out = \"\";\n  let i = 0;\n  for (const t of tokens) {\n    if (t.kind === \"slot\") {\n      if (i >= typed.length) break;\n      out += typed[i];\n      i += 1;\n    } else {\n      if (i === 0) break;\n      out += t.char;\n    }\n  }\n  return out;\n}\n\nlet rejectSeq = 0;\n\nexport function StencilFill({\n  mask,\n  label,\n  formatDescription,\n  validate = defaultValidate,\n  transform = defaultTransform,\n  rejectMessage = defaultRejectMessage,\n  name,\n  autoComplete = \"off\",\n  defaultValue = \"\",\n  onValueChange,\n  className = \"\",\n}: StencilFillProps) {\n  const autoId = useId().replace(/:/g, \"\");\n  const tokens = useMemo(() => parseMask(mask), [mask]);\n  const totalSlots = useMemo(() => tokens.filter((t) => t.kind === \"slot\").length, [tokens]);\n  const separatorChars = useMemo(\n    () => new Set(tokens.filter((t) => t.kind === \"sep\").map((t) => t.char)),\n    [tokens],\n  );\n  const description = formatDescription ?? deriveFormatDescription(mask);\n\n  const [typed, setTyped] = useState<string[]>(() => {\n    const chars: string[] = [];\n    for (const ch of defaultValue) {\n      if (chars.length >= totalSlots) break;\n      if (/[A-Za-z0-9]/.test(ch)) chars.push(ch.toUpperCase());\n    }\n    return chars;\n  });\n  const [focused, setFocused] = useState(false);\n  // Mirrors `focused` for the caret's visibility so the caret still shows up\n  // under autoplay: the demo subtree is `inert` there, and `inert` elements\n  // are not focusable, so the driver's synthetic keydowns never produce a\n  // real `focus` event even though they do drive typing. Set on any handled\n  // key/paste, cleared on blur — under real interaction it always coincides\n  // with `focused`, so behavior for a real user is unchanged.\n  const [armed, setArmed] = useState(false);\n  const [rejected, setRejected] = useState<{ slot: number; char: string; id: number } | null>(null);\n  const [announce, setAnnounce] = useState(\"\");\n\n  const typedRef = useRef(typed);\n  typedRef.current = typed;\n  const reducedRef = useRef(false);\n  const rejectFallbackRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\n  const cascadeTimersRef = useRef<ReturnType<typeof setTimeout>[]>([]);\n  const announceTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\n\n  useEffect(() => {\n    const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    reducedRef.current = mq.matches;\n    const onChange = () => {\n      reducedRef.current = mq.matches;\n    };\n    mq.addEventListener(\"change\", onChange);\n    return () => mq.removeEventListener(\"change\", onChange);\n  }, []);\n\n  useEffect(\n    () => () => {\n      clearTimeout(rejectFallbackRef.current);\n      clearTimeout(announceTimerRef.current);\n      cascadeTimersRef.current.forEach(clearTimeout);\n    },\n    [],\n  );\n\n  const commitTyped = useCallback(\n    (next: string[]) => {\n      setTyped(next);\n      onValueChange?.(buildValue(tokens, next));\n    },\n    [tokens, onValueChange],\n  );\n\n  const say = useCallback((msg: string) => {\n    clearTimeout(announceTimerRef.current);\n    setAnnounce(\"\");\n    announceTimerRef.current = setTimeout(() => setAnnounce(msg), 30);\n  }, []);\n\n  const triggerReject = useCallback(\n    (slot: number, char: string) => {\n      clearTimeout(rejectFallbackRef.current);\n      rejectSeq += 1;\n      const id = rejectSeq;\n      setRejected({ slot, char, id });\n      say(rejectMessage(char));\n      rejectFallbackRef.current = setTimeout(() => {\n        setRejected((r) => (r?.id === id ? null : r));\n      }, 900);\n    },\n    [rejectMessage, say],\n  );\n\n  const handleKeyDown = useCallback(\n    (e: KeyboardEvent<HTMLInputElement>) => {\n      if (e.ctrlKey || e.metaKey || e.altKey) return;\n\n      if (e.key === \"Backspace\" || e.key === \"Delete\") {\n        e.preventDefault();\n        setArmed(true);\n        clearTimeout(rejectFallbackRef.current);\n        setRejected(null);\n        if (typedRef.current.length) commitTyped(typedRef.current.slice(0, -1));\n        return;\n      }\n\n      if (e.key.length !== 1) return; // let Tab, Enter, arrows, Escape through natively\n      e.preventDefault();\n      setArmed(true);\n\n      if (separatorChars.has(e.key)) return; // literal separator: auto-skipped, not a rejection\n      if (typedRef.current.length >= totalSlots) return; // full: no-op, not a rejection\n\n      if (validate(e.key)) {\n        clearTimeout(rejectFallbackRef.current);\n        setRejected(null);\n        commitTyped([...typedRef.current, transform(e.key)]);\n      } else {\n        triggerReject(typedRef.current.length, e.key);\n      }\n    },\n    [commitTyped, separatorChars, totalSlots, transform, triggerReject, validate],\n  );\n\n  const handlePaste = useCallback(\n    (e: ClipboardEvent<HTMLInputElement>) => {\n      e.preventDefault();\n      setArmed(true);\n      const text = e.clipboardData.getData(\"text\");\n      const accepted: string[] = [];\n      for (const ch of text) {\n        if (typedRef.current.length + accepted.length >= totalSlots) break;\n        if (separatorChars.has(ch)) continue;\n        if (validate(ch)) accepted.push(transform(ch));\n      }\n      if (!accepted.length) return;\n\n      cascadeTimersRef.current.forEach(clearTimeout);\n      cascadeTimersRef.current = [];\n      const step = reducedRef.current ? 0 : 45;\n      accepted.forEach((ch, i) => {\n        const timer = setTimeout(() => {\n          if (typedRef.current.length >= totalSlots) return;\n          commitTyped([...typedRef.current, ch]);\n        }, step * i);\n        cascadeTimersRef.current.push(timer);\n      });\n    },\n    [commitTyped, separatorChars, totalSlots, transform, validate],\n  );\n\n  // Safety net for paths that never fire our own keydown handler — IME\n  // composition landing its result, browser autofill, drag-and-drop text,\n  // or a mobile virtual keyboard that reports `key: \"Unidentified\"` — all of\n  // which still produce a real `input`/`change` event. Every ordinary\n  // keystroke is already `preventDefault()`-ed above, so the DOM input's\n  // displayed value never actually changes on that path and this handler is\n  // a no-op for it; it only ever runs for value writes we didn't intercept.\n  const handleChange = useCallback(\n    (e: ChangeEvent<HTMLInputElement>) => {\n      const next: string[] = [];\n      for (const ch of e.target.value) {\n        if (next.length >= totalSlots) break;\n        if (separatorChars.has(ch)) continue;\n        if (validate(ch)) next.push(transform(ch));\n      }\n      const prev = typedRef.current;\n      if (next.length === prev.length && next.every((c, i) => c === prev[i])) return;\n      commitTyped(next);\n    },\n    [commitTyped, separatorChars, totalSlots, transform, validate],\n  );\n\n  const value = useMemo(() => buildValue(tokens, typed), [tokens, typed]);\n  const describedById = `sf-desc-${autoId}`;\n  const inputId = `sf-input-${autoId}`;\n\n  let slotIndex = -1;\n\n  return (\n    <div className={`relative inline-block ${className}`}>\n      <style>{CSS}</style>\n      <span id={describedById} className=\"sr-only\">\n        {description}\n      </span>\n      <span role=\"status\" aria-live=\"polite\" aria-atomic=\"true\" className=\"sr-only\">\n        {announce}\n      </span>\n      <label htmlFor={inputId} className=\"sr-only\">\n        {label}\n      </label>\n\n      <div\n        className=\"relative inline-flex h-[2.7em] items-center rounded-[0.67em] border border-border bg-background px-[0.67em]\"\n        style={{ fontSize: \"var(--sf-size, 1.125rem)\" }}\n      >\n        <input\n          id={inputId}\n          name={name}\n          type=\"text\"\n          inputMode=\"text\"\n          autoComplete={autoComplete}\n          autoCapitalize=\"characters\"\n          spellCheck={false}\n          aria-describedby={describedById}\n          value={value}\n          onChange={handleChange}\n          onKeyDown={handleKeyDown}\n          onPaste={handlePaste}\n          onFocus={() => setFocused(true)}\n          onBlur={() => {\n            setFocused(false);\n            setArmed(false);\n          }}\n          className=\"ns-sf-input absolute inset-0 z-10 h-full w-full rounded-[12px]\"\n        />\n        <div\n          aria-hidden=\"true\"\n          data-sf-glyphs\n          className=\"pointer-events-none flex items-center font-mono\"\n        >\n          {tokens.map((t, i) => {\n            if (t.kind === \"sep\") {\n              return (\n                <span key={i} className=\"ns-sf-cell ns-sf-sep\">\n                  {t.char}\n                </span>\n              );\n            }\n            slotIndex += 1;\n            const mySlot = slotIndex;\n            const filled = mySlot < typed.length;\n            const isNextOpen = mySlot === typed.length;\n            const isRejected = rejected?.slot === mySlot;\n            return (\n              <span key={i} className=\"ns-sf-cell relative inline-flex items-center justify-center\">\n                {(focused || armed) && isNextOpen && !isRejected && (\n                  <span aria-hidden=\"true\" className=\"ns-sf-caret\" />\n                )}\n                <span className={`ns-sf-glyph ${filled ? \"ns-sf-ink\" : \"ns-sf-stencil\"}`}>\n                  {filled ? typed[mySlot] : \"X\"}\n                </span>\n                {isRejected && rejected && (\n                  <span\n                    key={rejected.id}\n                    aria-hidden=\"true\"\n                    className=\"ns-sf-reject\"\n                    onAnimationEnd={() => setRejected((r) => (r?.id === rejected.id ? null : r))}\n                  >\n                    {rejected.char}\n                  </span>\n                )}\n              </span>\n            );\n          })}\n        </div>\n      </div>\n    </div>\n  );\n}\n\nconst CSS = `\n.ns-sf-input{ background: transparent; color: transparent; caret-color: transparent; border: none; padding: 0; font: inherit; outline: none; border-radius: inherit; transition: box-shadow 150ms ease-out; }\n.ns-sf-input:hover{ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--border), var(--foreground) 35%); }\n.ns-sf-input:focus-visible{ outline: 0.11em solid var(--ns-accent); outline-offset: 0.11em; box-shadow: none; }\n.ns-sf-cell{ display: inline-flex; align-items: center; justify-content: center; width: 1.2ch; text-align: center; }\n.ns-sf-glyph{ display: inline-block; min-width: 1ch; transition: color 90ms ease-out, opacity 90ms ease-out, font-weight 90ms ease-out; }\n.ns-sf-stencil{ color: var(--ns-muted); opacity: 0.62; font-weight: 400; }\n.ns-sf-ink{ color: var(--foreground); opacity: 1; font-weight: 600; }\n.ns-sf-sep{ color: var(--ns-muted); opacity: 0.62; }\n.ns-sf-caret{ position: absolute; left: -1px; top: 50%; width: 0.11em; height: 1.15em; transform: translateY(-50%); background: var(--ns-accent); border-radius: 1px; animation: ns-sf-blink 1s steps(1, jump-none) infinite; }\n.ns-sf-reject{ position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; color: var(--ns-muted); font-weight: 500; pointer-events: none; animation: ns-sf-reject-spring 560ms cubic-bezier(0.34, 1.56, 0.64, 1) forwards; }\n@keyframes ns-sf-blink{ 0%, 49%{ opacity: 1; } 50%, 100%{ opacity: 0; } }\n@keyframes ns-sf-reject-spring{\n  0%{ opacity: 0; transform: translate(0, 0); }\n  10%{ opacity: 0.4; transform: translate(0, 0); }\n  28%{ opacity: 0.4; transform: translate(-3px, 0); }\n  42%{ opacity: 0.4; transform: translate(2.4px, 0); }\n  56%{ opacity: 0.4; transform: translate(-1.6px, 0); }\n  70%{ opacity: 0.4; transform: translate(0.8px, 0); }\n  82%{ opacity: 0.4; transform: translate(0, 0); }\n  100%{ opacity: 0; transform: translate(0, 7px); }\n}\n@media (prefers-reduced-motion: reduce){\n  .ns-sf-caret{ animation: none; opacity: 1; }\n  .ns-sf-reject{ animation: ns-sf-reject-fade 260ms ease-out forwards; }\n  .ns-sf-glyph{ transition: color 60ms linear, opacity 60ms linear; }\n}\n@keyframes ns-sf-reject-fade{\n  0%{ opacity: 0; }\n  30%{ opacity: 0.4; }\n  100%{ opacity: 0; }\n}\n`;\n\nexport default StencilFill;\n",
      "type": "registry:ui",
      "target": "components/ui/stencil-fill.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-ns-accent": "var(--ns-accent)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "ns-accent": "#006bff"
    },
    "dark": {
      "ns-muted": "#8f8f8f"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "input",
      "form",
      "mask",
      "validation",
      "license-key",
      "accessibility"
    ],
    "instruction": "Build a reusable pattern-masked input driven by a `mask` prop string ('X' = editable cell, any other character = a literal separator, e.g. \"XXXX-XXXX-XXXX-XXXX\"). At rest, render the ENTIRE mask verbatim as a muted stencil: every 'X' cell shows the literal glyph 'X' in `var(--ns-muted)` at ~50% opacity, and every separator cell shows its literal character (also muted) already in place — the format is visible before the first keystroke, never auto-inserted as-you-type. A single native `<input>` (transparent text and caret via `color:transparent; caret-color:transparent`, absolutely positioned to exactly overlay the decorative cell grid beneath it — same split as card-number-emboss: real input drives state/focus/label/autocomplete, `aria-hidden` decorative layer is what's actually seen) owns all interaction. Because the mask model is append-then-backspace (not free mid-string cursor editing, matching the rest of the registry's structured-entry components), intercept every printable keydown yourself: `preventDefault()` always, validate the character against a `validate(char)` predicate (default `/^[A-Za-z0-9]$/`), and if it passes, transform it (default: uppercase) and append it to an internal typed-character array, advancing to the next open cell. If the character equals one of the mask's own literal separator characters, silently no-op — that is the 'auto-skip', never a rejection. Backspace/Delete removes the last typed character. On accept, the cell's glyph steps from stencil to inked with a 90ms transition (opacity 0.5->1, `var(--ns-muted)` -> `var(--foreground)`, font-weight 400->600) — a state change, not a spring. The emitted `value` (via `onValueChange`) is built by walking the mask and interleaving typed characters with literal separators, so it keeps the separators exactly where the mask puts them — never raw digits without them.\n\nThe rejection beat is the signature interaction: when a keystroke fails `validate`, do NOT just drop it. Show it: in that character's target cell (the next open one), render an absolutely-positioned overlay printing the refused raw character at `var(--ns-muted)`, animate it through a CSS keyframe — appear to ~40% opacity, a 3px-amplitude decaying horizontal shake (a few decreasing-amplitude translateX steps standing in for a spring, not a linear tween), then fall (translateY down, fade to 0 opacity) over roughly 550ms — and clear the overlay on `animationend` (with a small timeout fallback in case the event is missed). Simultaneously fire a polite `aria-live` announcement built from a `rejectMessage(char)` callback (default: \"Letter X not allowed\" for a letter, \"Character \\\"x\\\" not allowed\" otherwise) — clear the live region text and re-set it on a short delay so two consecutive rejections of the same character still both get announced, since an unchanged live-region string doesn't reliably re-fire in every screen reader. Reduced motion: replace the shake+fall keyframe with a plain 260ms fade (print in, fade out, no transform) via a `prefers-reduced-motion: reduce` media query on the same class, so the rejection is still visibly communicated without motion.\n\nPaste: intercept `onPaste`, prevent default, filter the clipboard text through the same `validate`/separator-skip logic (invalid or over-capacity characters are silently dropped, not individually shaken — the shake is a keystroke-time beat), then commit the accepted characters into the typed array via a short staggered cascade (~45ms per character via sequential `setTimeout`s, collapsed to no stagger under reduced motion) so the paste visibly inks left-to-right rather than jumping to its final state in one frame. Clear all pending stagger timers on unmount and whenever a new paste starts.\n\nThe caret is a kerf-thin (2px) `var(--ns-accent)` bar, vertically centered, rendered only while the real input is focused, positioned at the start of the next open editable cell (never inside a separator cell) — it blinks via a `steps()` keyframe, suppressed (solid, no animation) under reduced motion. Accessibility: a real `<label>` (visually `sr-only`, associated via `htmlFor`) supplies the accessible name; `aria-describedby` points at a `sr-only` span stating the format contract up front — auto-derive it from the mask when groups are uniform (\"Format: N groups of M characters, separated by <sep>\"), overridable via a `formatDescription` prop for irregular masks. The entire decorative glyph layer is `aria-hidden`. No dependencies."
  },
  "type": "registry:ui"
}