Skip to main content

ns-ui

Stencil Fill

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.

Use when a general structured-string field — license keys, IBANs, phone numbers, ticket IDs — where the format should be stated up front as a visible stencil template rather than guessed at or auto-punctuated as the user types, and a rejected character should be shown, not just refused. Pick card-number-emboss instead for the fixed payment-card layout with Luhn validation and a brand watermark; pick terminator-date-field instead for a calendar date with its own segment semantics. stencil-fill is the reusable mask mechanism underneath either of those, not a competing format-specific treatment.

Install

npx shadcn add https://design.helpmarq.com/r/stencil-fill.json

Ask AI

Point an assistant at this component's docs (llms-full.txt) with one click.

Claude, ChatGPT, Grok, and Perplexity open with the prompt already in. Gemini copies it to your clipboard first — paste it in once the chat opens.

Source
registry/core/stencil-fill/component.tsx
"use client";

import {
  useCallback,
  useEffect,
  useId,
  useMemo,
  useRef,
  useState,
  type ChangeEvent,
  type ClipboardEvent,
  type KeyboardEvent,
} from "react";

// ---------------------------------------------------------------------------
// StencilFill — a reusable pattern-masked input. The full template is
// visible at rest as a muted stencil (e.g. "XXXX-XXXX-XXXX", literal
// separators already in place); each typed character inks its own cell from
// --ns-muted to --foreground. One native <input> (transparent text/caret,
// positioned exactly over the decorative glyph grid — the same "real control
// drives, decorative layer displays" split as card-number-emboss) owns
// focus, value, label and autocomplete semantics, so the emitted value keeps
// its separators. A rejected character prints in its own cell at 40% muted,
// shakes on a spring, then falls away — the signature "show what was
// refused" beat — while aria-describedby states the format contract before
// the first keystroke, rather than auto-inserting separators as you type.
//
// This is the reusable MASK MECHANISM (license keys, IBANs, phone numbers,
// ticket IDs — any 'X'-templated string), not a format-specific treatment:
// card-number-emboss embosses one fixed payment layout, this stencils
// whatever mask you hand it.
// ---------------------------------------------------------------------------

export interface StencilFillProps {
  /** Template string: 'X' = editable cell, any other character = literal separator (auto-skipped, never typed). */
  mask: string;
  /** Accessible name for the underlying input. */
  label: string;
  /** Overrides the auto-derived aria-describedby copy. */
  formatDescription?: string;
  /** Per-character acceptance check. Defaults to A-Z / 0-9. */
  validate?: (char: string) => boolean;
  /** Normalizes an accepted character before it's inked. Defaults to uppercase. */
  transform?: (char: string) => string;
  /** Builds the polite live-region message for a refused character. */
  rejectMessage?: (char: string) => string;
  name?: string;
  autoComplete?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  className?: string;
}

interface MaskToken {
  kind: "slot" | "sep";
  char: string;
}

function parseMask(mask: string): MaskToken[] {
  return mask.split("").map((char) => (char === "X" ? { kind: "slot", char } : { kind: "sep", char }));
}

function deriveFormatDescription(mask: string): string {
  const groups = mask.match(/X+/g) ?? [];
  const seps = Array.from(new Set(mask.replace(/X/g, "").split("").filter(Boolean)));
  const firstGroupLen = groups[0]?.length ?? 0;
  if (groups.length > 1 && groups.every((g) => g.length === firstGroupLen) && seps.length === 1) {
    return `Format: ${groups.length} groups of ${firstGroupLen} characters, separated by ${seps[0]}`;
  }
  if (groups.length === 1 && seps.length === 0) {
    return `Format: ${firstGroupLen} characters`;
  }
  return `Format: ${mask}`;
}

function defaultValidate(char: string): boolean {
  return /^[A-Za-z0-9]$/.test(char);
}

function defaultTransform(char: string): string {
  return char.toUpperCase();
}

function defaultRejectMessage(char: string): string {
  return /^[a-zA-Z]$/.test(char) ? `Letter ${char.toUpperCase()} not allowed` : `Character "${char}" not allowed`;
}

function buildValue(tokens: MaskToken[], typed: string[]): string {
  let out = "";
  let i = 0;
  for (const t of tokens) {
    if (t.kind === "slot") {
      if (i >= typed.length) break;
      out += typed[i];
      i += 1;
    } else {
      if (i === 0) break;
      out += t.char;
    }
  }
  return out;
}

let rejectSeq = 0;

export function StencilFill({
  mask,
  label,
  formatDescription,
  validate = defaultValidate,
  transform = defaultTransform,
  rejectMessage = defaultRejectMessage,
  name,
  autoComplete = "off",
  defaultValue = "",
  onValueChange,
  className = "",
}: StencilFillProps) {
  const autoId = useId().replace(/:/g, "");
  const tokens = useMemo(() => parseMask(mask), [mask]);
  const totalSlots = useMemo(() => tokens.filter((t) => t.kind === "slot").length, [tokens]);
  const separatorChars = useMemo(
    () => new Set(tokens.filter((t) => t.kind === "sep").map((t) => t.char)),
    [tokens],
  );
  const description = formatDescription ?? deriveFormatDescription(mask);

  const [typed, setTyped] = useState<string[]>(() => {
    const chars: string[] = [];
    for (const ch of defaultValue) {
      if (chars.length >= totalSlots) break;
      if (/[A-Za-z0-9]/.test(ch)) chars.push(ch.toUpperCase());
    }
    return chars;
  });
  const [focused, setFocused] = useState(false);
  // Mirrors `focused` for the caret's visibility so the caret still shows up
  // under autoplay: the demo subtree is `inert` there, and `inert` elements
  // are not focusable, so the driver's synthetic keydowns never produce a
  // real `focus` event even though they do drive typing. Set on any handled
  // key/paste, cleared on blur — under real interaction it always coincides
  // with `focused`, so behavior for a real user is unchanged.
  const [armed, setArmed] = useState(false);
  const [rejected, setRejected] = useState<{ slot: number; char: string; id: number } | null>(null);
  const [announce, setAnnounce] = useState("");

  const typedRef = useRef(typed);
  typedRef.current = typed;
  const reducedRef = useRef(false);
  const rejectFallbackRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
  const cascadeTimersRef = useRef<ReturnType<typeof setTimeout>[]>([]);
  const announceTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);

  useEffect(() => {
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    reducedRef.current = mq.matches;
    const onChange = () => {
      reducedRef.current = mq.matches;
    };
    mq.addEventListener("change", onChange);
    return () => mq.removeEventListener("change", onChange);
  }, []);

  useEffect(
    () => () => {
      clearTimeout(rejectFallbackRef.current);
      clearTimeout(announceTimerRef.current);
      cascadeTimersRef.current.forEach(clearTimeout);
    },
    [],
  );

  const commitTyped = useCallback(
    (next: string[]) => {
      setTyped(next);
      onValueChange?.(buildValue(tokens, next));
    },
    [tokens, onValueChange],
  );

  const say = useCallback((msg: string) => {
    clearTimeout(announceTimerRef.current);
    setAnnounce("");
    announceTimerRef.current = setTimeout(() => setAnnounce(msg), 30);
  }, []);

  const triggerReject = useCallback(
    (slot: number, char: string) => {
      clearTimeout(rejectFallbackRef.current);
      rejectSeq += 1;
      const id = rejectSeq;
      setRejected({ slot, char, id });
      say(rejectMessage(char));
      rejectFallbackRef.current = setTimeout(() => {
        setRejected((r) => (r?.id === id ? null : r));
      }, 900);
    },
    [rejectMessage, say],
  );

  const handleKeyDown = useCallback(
    (e: KeyboardEvent<HTMLInputElement>) => {
      if (e.ctrlKey || e.metaKey || e.altKey) return;

      if (e.key === "Backspace" || e.key === "Delete") {
        e.preventDefault();
        setArmed(true);
        clearTimeout(rejectFallbackRef.current);
        setRejected(null);
        if (typedRef.current.length) commitTyped(typedRef.current.slice(0, -1));
        return;
      }

      if (e.key.length !== 1) return; // let Tab, Enter, arrows, Escape through natively
      e.preventDefault();
      setArmed(true);

      if (separatorChars.has(e.key)) return; // literal separator: auto-skipped, not a rejection
      if (typedRef.current.length >= totalSlots) return; // full: no-op, not a rejection

      if (validate(e.key)) {
        clearTimeout(rejectFallbackRef.current);
        setRejected(null);
        commitTyped([...typedRef.current, transform(e.key)]);
      } else {
        triggerReject(typedRef.current.length, e.key);
      }
    },
    [commitTyped, separatorChars, totalSlots, transform, triggerReject, validate],
  );

  const handlePaste = useCallback(
    (e: ClipboardEvent<HTMLInputElement>) => {
      e.preventDefault();
      setArmed(true);
      const text = e.clipboardData.getData("text");
      const accepted: string[] = [];
      for (const ch of text) {
        if (typedRef.current.length + accepted.length >= totalSlots) break;
        if (separatorChars.has(ch)) continue;
        if (validate(ch)) accepted.push(transform(ch));
      }
      if (!accepted.length) return;

      cascadeTimersRef.current.forEach(clearTimeout);
      cascadeTimersRef.current = [];
      const step = reducedRef.current ? 0 : 45;
      accepted.forEach((ch, i) => {
        const timer = setTimeout(() => {
          if (typedRef.current.length >= totalSlots) return;
          commitTyped([...typedRef.current, ch]);
        }, step * i);
        cascadeTimersRef.current.push(timer);
      });
    },
    [commitTyped, separatorChars, totalSlots, transform, validate],
  );

  // Safety net for paths that never fire our own keydown handler — IME
  // composition landing its result, browser autofill, drag-and-drop text,
  // or a mobile virtual keyboard that reports `key: "Unidentified"` — all of
  // which still produce a real `input`/`change` event. Every ordinary
  // keystroke is already `preventDefault()`-ed above, so the DOM input's
  // displayed value never actually changes on that path and this handler is
  // a no-op for it; it only ever runs for value writes we didn't intercept.
  const handleChange = useCallback(
    (e: ChangeEvent<HTMLInputElement>) => {
      const next: string[] = [];
      for (const ch of e.target.value) {
        if (next.length >= totalSlots) break;
        if (separatorChars.has(ch)) continue;
        if (validate(ch)) next.push(transform(ch));
      }
      const prev = typedRef.current;
      if (next.length === prev.length && next.every((c, i) => c === prev[i])) return;
      commitTyped(next);
    },
    [commitTyped, separatorChars, totalSlots, transform, validate],
  );

  const value = useMemo(() => buildValue(tokens, typed), [tokens, typed]);
  const describedById = `sf-desc-${autoId}`;
  const inputId = `sf-input-${autoId}`;

  let slotIndex = -1;

  return (
    <div className={`relative inline-block ${className}`}>
      <style>{CSS}</style>
      <span id={describedById} className="sr-only">
        {description}
      </span>
      <span role="status" aria-live="polite" aria-atomic="true" className="sr-only">
        {announce}
      </span>
      <label htmlFor={inputId} className="sr-only">
        {label}
      </label>

      <div
        className="relative inline-flex h-[2.7em] items-center rounded-[0.67em] border border-border bg-background px-[0.67em]"
        style={{ fontSize: "var(--sf-size, 1.125rem)" }}
      >
        <input
          id={inputId}
          name={name}
          type="text"
          inputMode="text"
          autoComplete={autoComplete}
          autoCapitalize="characters"
          spellCheck={false}
          aria-describedby={describedById}
          value={value}
          onChange={handleChange}
          onKeyDown={handleKeyDown}
          onPaste={handlePaste}
          onFocus={() => setFocused(true)}
          onBlur={() => {
            setFocused(false);
            setArmed(false);
          }}
          className="ns-sf-input absolute inset-0 z-10 h-full w-full rounded-[12px]"
        />
        <div
          aria-hidden="true"
          data-sf-glyphs
          className="pointer-events-none flex items-center font-mono"
        >
          {tokens.map((t, i) => {
            if (t.kind === "sep") {
              return (
                <span key={i} className="ns-sf-cell ns-sf-sep">
                  {t.char}
                </span>
              );
            }
            slotIndex += 1;
            const mySlot = slotIndex;
            const filled = mySlot < typed.length;
            const isNextOpen = mySlot === typed.length;
            const isRejected = rejected?.slot === mySlot;
            return (
              <span key={i} className="ns-sf-cell relative inline-flex items-center justify-center">
                {(focused || armed) && isNextOpen && !isRejected && (
                  <span aria-hidden="true" className="ns-sf-caret" />
                )}
                <span className={`ns-sf-glyph ${filled ? "ns-sf-ink" : "ns-sf-stencil"}`}>
                  {filled ? typed[mySlot] : "X"}
                </span>
                {isRejected && rejected && (
                  <span
                    key={rejected.id}
                    aria-hidden="true"
                    className="ns-sf-reject"
                    onAnimationEnd={() => setRejected((r) => (r?.id === rejected.id ? null : r))}
                  >
                    {rejected.char}
                  </span>
                )}
              </span>
            );
          })}
        </div>
      </div>
    </div>
  );
}

const CSS = `
.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; }
.ns-sf-input:hover{ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--border), var(--foreground) 35%); }
.ns-sf-input:focus-visible{ outline: 0.11em solid var(--ns-accent); outline-offset: 0.11em; box-shadow: none; }
.ns-sf-cell{ display: inline-flex; align-items: center; justify-content: center; width: 1.2ch; text-align: center; }
.ns-sf-glyph{ display: inline-block; min-width: 1ch; transition: color 90ms ease-out, opacity 90ms ease-out, font-weight 90ms ease-out; }
.ns-sf-stencil{ color: var(--ns-muted); opacity: 0.62; font-weight: 400; }
.ns-sf-ink{ color: var(--foreground); opacity: 1; font-weight: 600; }
.ns-sf-sep{ color: var(--ns-muted); opacity: 0.62; }
.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; }
.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; }
@keyframes ns-sf-blink{ 0%, 49%{ opacity: 1; } 50%, 100%{ opacity: 0; } }
@keyframes ns-sf-reject-spring{
  0%{ opacity: 0; transform: translate(0, 0); }
  10%{ opacity: 0.4; transform: translate(0, 0); }
  28%{ opacity: 0.4; transform: translate(-3px, 0); }
  42%{ opacity: 0.4; transform: translate(2.4px, 0); }
  56%{ opacity: 0.4; transform: translate(-1.6px, 0); }
  70%{ opacity: 0.4; transform: translate(0.8px, 0); }
  82%{ opacity: 0.4; transform: translate(0, 0); }
  100%{ opacity: 0; transform: translate(0, 7px); }
}
@media (prefers-reduced-motion: reduce){
  .ns-sf-caret{ animation: none; opacity: 1; }
  .ns-sf-reject{ animation: ns-sf-reject-fade 260ms ease-out forwards; }
  .ns-sf-glyph{ transition: color 60ms linear, opacity 60ms linear; }
}
@keyframes ns-sf-reject-fade{
  0%{ opacity: 0; }
  30%{ opacity: 0.4; }
  100%{ opacity: 0; }
}
`;

export default StencilFill;
Build spec

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. The 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. Paste: 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. The 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.

Props

PropTypeDefaultDescription
maskstringTemplate string: 'X' = editable cell, any other character = literal separator (auto-skipped, never typed).
labelstringAccessible name for the underlying input.
formatDescription?stringOverrides the auto-derived aria-describedby copy.
validate?(char: string) => booleandefaultValidatePer-character acceptance check. Defaults to A-Z / 0-9.
transform?(char: string) => stringdefaultTransformNormalizes an accepted character before it's inked. Defaults to uppercase.
rejectMessage?(char: string) => stringdefaultRejectMessageBuilds the polite live-region message for a refused character.
name?string
autoComplete?string"off"
defaultValue?string
onValueChange?(value: string) => void
className?string