Slug Field Mirror

Field

A primary field with a slugified 'carbon copy' stacked underneath it, offset like a duplicate sheet peeking out from beneath a receipt.

Install
npx shadcn add https://design.helpmarq.com/r/slug-field-mirror.json
Source
registry/core/slug-field-mirror/component.tsx
"use client";

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

// ---------------------------------------------------------------------------
// CarbonFlimsy — a primary text field with a "carbon copy" derived field
// stacked underneath it. Every keystroke in the primary is transformed
// (default: slugified) and stamped onto the flimsy sheet: each newly-arrived
// character mounts fresh and plays a short pressure-jitter keyframe
// (translate 0.4px, font-weight 600 -> 400), staggered ~15ms apart when
// several characters land at once (paste, or a full re-stamp). The flimsy
// sheet sits visually offset 4px down-right from the primary, like a second
// page peeking out from underneath — while linked its border and background
// read as a thin under-sheet (color-mix toward --muted).
//
// Editing the flimsy directly detaches it: the offset springs back to zero,
// the border firms to --foreground at 30%, mirroring stops, and the value
// becomes independently editable. A "Relink" button (only rendered while
// detached) restores the coupling and replays a full re-stamp sweep across
// the freshly-derived value.
//
// Both fields are real, independently labelled <input> elements — the only
// decorative-only layer is the stamp overlay, an aria-hidden row of spans
// painted exactly over the flimsy input while its own text is transparent
// (caret-color kept so a real caret still shows). The flimsy input always
// carries the accessible value; the overlay never diverges from it. Detach
// and relink each fire one polite live-region announcement. Reduced motion
// drops the jitter and the spring transition; nothing about legibility or
// operability depends on either.
// ---------------------------------------------------------------------------

type Vars = CSSProperties & Record<`--${string}`, string | number>;

const CHAR_STAGGER_MS = 15;

function defaultTransform(value: string): string {
  return value
    .toLowerCase()
    .trim()
    .replace(/[^a-z0-9]+/g, "-")
    .replace(/^-+|-+$/g, "");
}

function commonPrefixLength(a: string, b: string): number {
  const n = Math.min(a.length, b.length);
  let i = 0;
  while (i < n && a[i] === b[i]) i++;
  return i;
}

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

interface Stamp {
  text: string;
  /** per-character "batch" id — a span's React key includes this, so only
   *  characters whose gen just changed remount and replay the jitter. */
  gen: number[];
  /** index the current batch's newly-stamped run starts at, for stagger delay. */
  from: number;
}

export interface CarbonFlimsyProps {
  /** Accessible label for the primary field, e.g. "Project title". */
  label: string;
  /** Accessible label for the derived flimsy field, e.g. "Slug". */
  derivedLabel: string;
  /** Controlled primary value. */
  value?: string;
  /** Initial primary value when uncontrolled. @default "" */
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  /** Fires whenever the derived value changes, with whether it's still linked. */
  onDerivedChange?: (value: string, linked: boolean) => void;
  /** Transforms the primary value into the derived one. @default slugify */
  transform?: (value: string) => string;
  placeholder?: string;
  /** Shown in the flimsy field while its value is empty. @default "auto-generated" */
  derivedPlaceholder?: string;
  className?: string;
}

export function CarbonFlimsy({
  label,
  derivedLabel,
  value,
  defaultValue = "",
  onValueChange,
  onDerivedChange,
  transform = defaultTransform,
  placeholder,
  derivedPlaceholder = "auto-generated",
  className = "",
}: CarbonFlimsyProps) {
  const isControlled = value !== undefined;
  const [internalPrimary, setInternalPrimary] = useState(defaultValue);
  const primary = isControlled ? (value as string) : internalPrimary;

  const [linked, setLinked] = useState(true);
  const [stamp, setStamp] = useState<Stamp>(() => {
    const t = transform(defaultValue);
    return { text: t, gen: Array.from(t, () => 0), from: 0 };
  });
  const [live, setLive] = useState("");

  const reducedMotion = useReducedMotion();
  const stampGenRef = useRef(0);
  const prevPrimaryRef = useRef(primary);
  const uid = useId();
  const primaryId = `${uid}-primary`;
  const derivedId = `${uid}-derived`;
  const hintId = `${uid}-hint`;

  const stampDerived = useCallback((next: string, full = false) => {
    setStamp((prev) => {
      const prefixLen = full ? 0 : commonPrefixLength(prev.text, next);
      stampGenRef.current += 1;
      const gen = stampGenRef.current;
      return {
        text: next,
        gen: Array.from(next, (_, i) => (i < prefixLen ? (prev.gen[i] ?? 0) : gen)),
        from: prefixLen,
      };
    });
  }, []);

  // Re-derive whenever the primary value actually changes while linked —
  // covers both the uncontrolled onChange path and an externally controlled
  // `value` update, without duplicating the derivation logic in either handler.
  useEffect(() => {
    if (!linked) {
      prevPrimaryRef.current = primary;
      return;
    }
    if (primary === prevPrimaryRef.current) return;
    prevPrimaryRef.current = primary;
    const next = transform(primary);
    stampDerived(next);
    onDerivedChange?.(next, true);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [primary, linked]);

  const handlePrimaryChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const next = e.target.value;
    if (!isControlled) setInternalPrimary(next);
    onValueChange?.(next);
  };

  const handleFlimsyChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const next = e.target.value;
    setStamp((prev) => ({ ...prev, text: next }));
    if (linked) {
      setLinked(false);
      setLive(`Detached. ${derivedLabel} no longer follows ${label}.`);
    }
    onDerivedChange?.(next, false);
  };

  const handleRelink = () => {
    const next = transform(primary);
    setLinked(true);
    stampDerived(next, true);
    setLive(`Relinked. ${derivedLabel} restored from ${label}.`);
    onDerivedChange?.(next, true);
  };

  return (
    <div
      className={`ns-cf-root flex w-full flex-col gap-1.5 ${className}`}
      data-reduced={reducedMotion || undefined}
    >
      <style>{`
.ns-cf-flimsy-box{transition:transform 340ms cubic-bezier(.34,1.56,.64,1),border-color 200ms ease,background-color 200ms ease,box-shadow 150ms ease}
.ns-cf-flimsy-box[data-linked]{transform:translate(4px,4px);border-color:var(--border);background:color-mix(in oklab,var(--muted) 14%,var(--background))}
.ns-cf-flimsy-box[data-detached]{transform:translate(0,0);border-color:color-mix(in oklab,var(--foreground) 30%,transparent);background:var(--background)}
.ns-cf-flimsy-box:focus-within{border-color:var(--accent);box-shadow:0 0 0 3px color-mix(in oklab,var(--accent) 18%,transparent)}
.ns-cf-flimsy-input[data-linked]{color:transparent;caret-color:var(--foreground)}
.ns-cf-char{display:inline-block;animation:ns-cf-stamp 120ms ease-out both;animation-delay:var(--cf-delay,0ms)}
@keyframes ns-cf-stamp{0%{transform:translate(0,0);font-weight:600}45%{transform:translate(.4px,.4px);font-weight:500}100%{transform:translate(0,0);font-weight:400}}
.ns-cf-primary-input:focus-visible,.ns-cf-flimsy-input:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
.ns-cf-relink:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
.ns-cf-root[data-reduced] .ns-cf-char{animation:none;font-weight:400}
.ns-cf-root[data-reduced] .ns-cf-flimsy-box{transition:none}
@media (prefers-reduced-motion: reduce){
.ns-cf-char{animation:none;font-weight:400}
.ns-cf-flimsy-box{transition:none}
}
`}</style>

      <div className="flex flex-col gap-1.5">
        <label
          htmlFor={primaryId}
          className="text-[13px] font-medium tracking-tight text-foreground"
        >
          {label}
        </label>
        <input
          id={primaryId}
          type="text"
          value={primary}
          placeholder={placeholder}
          onChange={handlePrimaryChange}
          data-cf-role="primary"
          className="ns-cf-primary-input h-9 rounded-[6px] border border-border bg-background px-3 text-sm text-foreground outline-none transition-colors duration-150 placeholder:text-muted/70 hover:border-foreground/25 focus:border-accent"
        />
      </div>

      <div className="relative mt-1 pb-1 pr-1">
        <div
          data-linked={linked || undefined}
          data-detached={!linked || undefined}
          className="ns-cf-flimsy-box flex flex-col gap-1 rounded-[12px] border px-3 py-2"
        >
          <div className="flex items-center justify-between gap-2">
            <label
              htmlFor={derivedId}
              className="text-[11px] font-medium uppercase tracking-[0.08em] text-muted"
            >
              {derivedLabel}
            </label>
            {!linked && (
              <button
                type="button"
                onClick={handleRelink}
                className="ns-cf-relink inline-flex items-center gap-1 rounded-[6px] px-1.5 py-0.5 text-[11px] font-medium text-accent transition-colors hover:text-accent-hover"
              >
                <svg aria-hidden="true" viewBox="0 0 12 12" className="h-[10px] w-[10px]">
                  <path
                    d="M4.6 4.6 1.8 7.4a1.7 1.7 0 0 0 2.4 2.4L7 7"
                    fill="none"
                    stroke="currentColor"
                    strokeWidth="1.3"
                    strokeLinecap="round"
                  />
                  <path
                    d="M7.4 7.4l2.8-2.8a1.7 1.7 0 0 0-2.4-2.4L5 5"
                    fill="none"
                    stroke="currentColor"
                    strokeWidth="1.3"
                    strokeLinecap="round"
                  />
                </svg>
                Relink
              </button>
            )}
          </div>

          <div className="relative">
            <input
              id={derivedId}
              type="text"
              value={stamp.text}
              placeholder={derivedPlaceholder}
              onChange={handleFlimsyChange}
              aria-describedby={hintId}
              data-linked={linked || undefined}
              className="ns-cf-flimsy-input h-7 w-full border-0 bg-transparent p-0 font-mono text-[13px] leading-7 text-foreground outline-none placeholder:text-muted/70"
            />
            {linked && (
              <div
                aria-hidden="true"
                className="ns-cf-overlay pointer-events-none absolute inset-0 flex items-center overflow-hidden whitespace-nowrap font-mono text-[13px] leading-7 text-foreground"
              >
                {Array.from(stamp.text).map((ch, i) => {
                  const delay = i >= stamp.from ? (i - stamp.from) * CHAR_STAGGER_MS : 0;
                  return (
                    <span
                      key={`${i}-${stamp.gen[i] ?? 0}`}
                      className="ns-cf-char"
                      style={{ "--cf-delay": `${delay}ms` } as Vars}
                    >
                      {ch === " " ? " " : ch}
                    </span>
                  );
                })}
              </div>
            )}
          </div>
        </div>
      </div>

      <p id={hintId} className="text-[12px] leading-4 text-muted">
        Auto-generated from {label}. Editing detaches it.
      </p>

      <span role="status" aria-live="polite" className="sr-only">
        {live}
      </span>
    </div>
  );
}
Use when

A primary field with a slugified 'carbon copy' stacked underneath it, offset like a duplicate sheet peeking out from beneath a receipt. Every keystroke stamps its transformed counterpart onto the flimsy with a tiny pressure jitter; editing the flimsy directly tears it off the coupling until a real Relink button re-stamps it.

Build spec

A primary labelled text input with a second, real, independently-labelled 'flimsy' input stacked directly under it, styled as a carbon-copy sheet: 12px border-radius, 1px --border, offset translate(4px,4px) so it visually peeks out from behind the primary's bottom-right corner, background color-mix(in oklab, var(--muted) 14%, var(--background)). RENDER: the flimsy input's own text is transparent (caret-color kept on --foreground so a real caret still shows) and an aria-hidden absolutely-positioned overlay of one <span> per character, in Geist Mono, paints the visible glyphs on top — the underlying real <input> always carries the true accessible value, the overlay never diverges from it, it is purely decorative. DERIVATION: every primary keystroke recomputes the flimsy value via a `transform` prop (default a kebab-case slugifier: lowercase, non-alphanumeric runs collapsed to single hyphens, trimmed) and diffs it against the previous derived string by common-prefix length; characters at or past that prefix are 'newly stamped' and remount (keyed on an incrementing per-batch generation id) so a CSS keyframe plays automatically on mount: translate(0,0)->translate(0.4px,0.4px)->translate(0,0) with font-weight 600->500->400 over 120ms, each newly-stamped character's animation-delay offset by its distance from the batch's start index times 15ms, so a single keystroke stamps instantly (0ms) while a paste or a full re-stamp sweeps left-to-right. DETACH: any onChange fired directly on the flimsy input (never fired by the programmatic mirror, only by real user input) immediately sets linked=false — the box's transform springs from translate(4px,4px) to translate(0,0) over 340ms cubic-bezier(.34,1.56,.64,1), its border firms from --border to color-mix(in oklab, var(--foreground) 30%, transparent), its background flattens to plain --background, the character overlay unmounts (the input's own text becomes visible and freely editable), and mirroring from the primary stops entirely. RELINK: a real <button>'Relink'</button> — rendered only while detached, an ordinary tabbable control, never the first interactive element at rest — recomputes the transform from the current primary value, sets linked=true (springing the offset/border/background back), and calls the stamp path with `full=true`, forcing every character of the fresh value to remount and sweep in together (prefixLen=0), reading as the sheet being re-inserted and stamped fresh. A11Y: both inputs have real <label for> bindings; the flimsy input carries aria-describedby pointing at a persistent helper paragraph reading 'Auto-generated from {label}. Editing detaches it.'; detach and relink each fire exactly one message into a role=status aria-live=polite region (not on every keystroke); the character jitter is purely visual and never affects the accessible value; Tab reaches the primary input, then the flimsy input, then Relink when it exists. REDUCED MOTION: prefers-reduced-motion (media query and a mirrored data-reduced attribute) drops the character keyframe entirely (characters just appear at 400 weight) and removes the box's spring transition, so detach/relink still change state instantly and legibly without any motion.

Tags
fieldslugderived-fieldformcarbon-copytypographymicro-interaction