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.

Usage

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

import { useState } from "react";
import { StencilFill } from "./component";

// Windows-style product-key mask (5 groups of 5) — the group shape reads as
// "software license key" at a glance, richer than a plain freeform string.
// 'O' is rejected so the signature reject beat (print at 40% muted,
// spring-shake, fall away) has something to demonstrate on its own — no
// pointer/keyboard input from a viewer required, autoplay's "type" mode
// drives real key events at the hidden input (see meta.json).
function validateLicenseChar(char: string): boolean {
  if (!/^[A-Za-z0-9]$/.test(char)) return false;
  return char.toUpperCase() !== "O";
}

export default function StencilFillDemo() {
  const [value, setValue] = useState("");

  return (
    <div className="relative flex min-h-screen flex-col items-center justify-center gap-8 overflow-hidden px-6 py-16">
      <svg aria-hidden="true" className="pointer-events-none absolute inset-0 h-full w-full">
        <defs>
          <pattern id="sf-dotgrid" width="28" height="28" patternUnits="userSpaceOnUse">
            <circle cx="1.4" cy="1.4" r="1.4" style={{ fill: "var(--border)" }} opacity="0.55" />
          </pattern>
        </defs>
        <rect width="100%" height="100%" fill="url(#sf-dotgrid)" />
      </svg>

      <p className="relative font-mono text-xs uppercase tracking-[0.2em] text-ns-muted">
        ns-ui / stencil-fill
      </p>

      <div
        className="relative flex flex-col items-center gap-8 rounded-[1.5rem] border border-border bg-background px-12 py-14 sm:px-16"
        style={{ boxShadow: "0 32px 70px -32px color-mix(in srgb, var(--foreground) 30%, transparent)" }}
      >
        <div style={{ "--sf-size": "2.35rem" } as React.CSSProperties}>
          <StencilFill
            mask="XXXXX-XXXXX-XXXXX-XXXXX-XXXXX"
            label="Product license key"
            validate={validateLicenseChar}
            autoComplete="off"
            onValueChange={setValue}
          />
        </div>

        <p className="max-w-md text-center text-xs text-ns-muted">
          Type to ink each cell from the stencil; a disallowed character (try
          &ldquo;O&rdquo;) prints faint, shakes, and falls away instead of
          silently vanishing.
        </p>

        <p aria-hidden="true" className="font-mono text-[10px] text-ns-muted">
          value: {value || "—"}
        </p>
      </div>
    </div>
  );
}

Component

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;

The usage example imports ./component because it sits next to the component file in this repo. Installed, that file lands at components/ui/stencil-fill.tsx, so point the import there.

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 date-picker-moon 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.

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
maskstring—Template string: 'X' = editable cell, any other character = literal separator (auto-skipped, never typed).
labelstring—Accessible name for the underlying input.
formatDescription?string—Overrides 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——