Skip to main content

ns-ui

Wizard Dovetail

A multi-step form whose completed steps physically interlock: each step contributes an SVG dovetail chip to a rail, sliding in to mate on a spring when valid, or bouncing off the joint and returning when it isn't.

Usage

registry/core/wizard-dovetail/demo.tsx
"use client";

import { DovetailRun, type DovetailStep } from "./component";

const steps: DovetailStep[] = [
  {
    id: "contact",
    title: "Contact",
    fields: [
      { id: "name", label: "Full name", required: true, autoComplete: "name" },
      { id: "email", label: "Email", type: "email", required: true, autoComplete: "email" },
    ],
  },
  {
    id: "shipping",
    title: "Shipping",
    fields: [
      { id: "address", label: "Address", required: true, autoComplete: "street-address" },
      { id: "city", label: "City", required: true, autoComplete: "address-level2" },
    ],
  },
  {
    id: "payment",
    title: "Payment",
    fields: [
      {
        id: "card",
        label: "Card number",
        required: true,
        placeholder: "4242 4242 4242 4242",
        validate: (v) => (v.replace(/\s/g, "").length < 12 ? "Card number looks too short." : null),
      },
      { id: "zip", label: "Billing ZIP", required: true, autoComplete: "postal-code" },
    ],
  },
];

// Contact is pre-filled and valid: the first Next always seats cleanly.
// Shipping and Payment start empty, so working through them honestly shows
// both mechanisms — a clean join, and a reject/error pass on whichever
// field still needs filling in.
const defaultValues = {
  name: "Priya Achari",
  email: "priya@example.com",
};

export default function DovetailRunDemo() {
  return (
    <main className="flex min-h-screen items-center justify-center bg-background p-6">
      <div className="w-full max-w-md">
        <p className="mb-4 font-mono text-xs tracking-widest text-ns-muted">ns-ui / wizard-dovetail</p>
        <h1 className="text-lg font-semibold text-foreground">Checkout</h1>
        <p className="mt-1 text-sm leading-relaxed text-ns-muted">
          Each completed step joins the rail as a physical dovetail chip. Submit clean and it
          seats with a spring; submit with a gap left in a field and the chip bounces off the
          joint instead of a red asterisk doing the talking.
        </p>

        <div className="mt-5">
          <DovetailRun
            steps={steps}
            defaultValues={defaultValues}
            submitLabel="Place order"
            onComplete={(values) => console.log("wizard-dovetail complete", values)}
          />
        </div>
      </div>
    </main>
  );
}

Component

registry/core/wizard-dovetail/component.tsx
"use client";

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

// ---------------------------------------------------------------------------
// DovetailRun — a multi-step form whose completed steps physically interlock.
// Each step is a real labelled <fieldset>; validating it on Next/Submit
// either SEATS its rail chip — a small SVG shape cut with dovetail edges
// (a flared tail on its trailing edge, a matching socket notch on its
// leading edge) that slides in from outside the rail on ease-out-expo, the
// final 12px settling on a spring with slight overshoot so the joint reads
// as mated — or REJECTS it: the same chip travels toward the joint,
// decelerates, bounces off it (a decaying two-swing recoil) and fades back
// out while per-field errors render below the form and an aria-live summary
// announces what still needs fixing. A step's chip only ever sits in the
// rail while its fields are genuinely valid; editing a seated step back into
// invalidity un-seats it — the dashed socket outline is the constant "gap"
// that shows exactly where work remains. Zero deps, DOM + SVG + CSS only.
// ---------------------------------------------------------------------------

export type DovetailField = {
  id: string;
  label: string;
  type?: "text" | "email" | "tel" | "number";
  required?: boolean;
  placeholder?: string;
  autoComplete?: string;
  /** return an error message for a non-empty value, or null/undefined if fine */
  validate?: (value: string) => string | null | undefined;
};

export type DovetailStep = {
  id: string;
  title: string;
  fields: DovetailField[];
};

export interface DovetailRunProps {
  /** the wizard steps, in order */
  steps: DovetailStep[];
  /** seed field values by field id, e.g. to demonstrate a pre-filled step */
  defaultValues?: Record<string, string>;
  /** label for the final step's submit button */
  submitLabel?: string;
  /** called with all field values, keyed by field id, once the final step submits */
  onComplete?: (values: Record<string, string>) => void;
  /** extra classes merged onto the rendered root element */
  className?: string;
}

// chip geometry — one shared shape, module-level so it's identical (and
// deterministic across renders/screenshots) for every chip. A dovetail
// tail flares OUT past the trailing (right) edge; the socket is the same
// trapezoid cut IN from the leading (left) edge, so an identical neighbor's
// tail nests into it.
const CHIP_W = 104;
const CHIP_H = 34;
const DEPTH = 6; // how far the tail protrudes / the socket cuts in
const FLARE = 3; // extra half-width at the tip vs the base — the flare
const TOOTH_H = 14; // vertical extent of the tooth, centered
const TOP_Y = CHIP_H / 2 - TOOTH_H / 2;
const BOT_Y = CHIP_H / 2 + TOOTH_H / 2;
const SVG_W = CHIP_W + DEPTH;
const TRAVEL_PX = 40; // distance a chip flies in from before it seats/rejects
const SEAT_MS = 650;
const REJECT_MS = 700;

// full closed outline — used for both the fill and the default-colour stroke
const FULL_D = [
  `M0,0`,
  `L${CHIP_W},0`,
  `L${CHIP_W},${TOP_Y}`,
  `L${CHIP_W + DEPTH},${TOP_Y - FLARE}`,
  `L${CHIP_W + DEPTH},${BOT_Y + FLARE}`,
  `L${CHIP_W},${BOT_Y}`,
  `L${CHIP_W},${CHIP_H}`,
  `L0,${CHIP_H}`,
  `L0,${BOT_Y}`,
  `L${DEPTH},${BOT_Y + FLARE}`,
  `L${DEPTH},${TOP_Y - FLARE}`,
  `L0,${TOP_Y}`,
  "Z",
].join(" ");

// just the leading (left/pin) edge — drawn again on top so it alone can be
// recoloured to --ns-accent on focus-within, without touching the fill path.
const LEFT_EDGE_D = [
  `M0,${CHIP_H}`,
  `L0,${BOT_Y}`,
  `L${DEPTH},${BOT_Y + FLARE}`,
  `L${DEPTH},${TOP_Y - FLARE}`,
  `L0,${TOP_Y}`,
  `L0,0`,
].join(" ");

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;
}

type StepStatus = "pending" | "complete";
type AnimPhase = "idle" | "seat" | "reject";

function RailSlot({
  step,
  index,
  status,
  isCurrent,
  animPhase,
}: {
  step: DovetailStep;
  index: number;
  status: StepStatus;
  isCurrent: boolean;
  animPhase: AnimPhase;
}) {
  const animating = animPhase !== "idle";
  const showChip = status === "complete" || animating;
  const stateWord =
    status === "complete" ? "complete" : isCurrent ? "current" : "not started";
  const travelStyle = { "--travel": `${TRAVEL_PX}px` } as CSSProperties;

  return (
    <li
      aria-current={isCurrent ? "step" : undefined}
      className="relative shrink-0"
      style={{ width: CHIP_W, height: CHIP_H }}
    >
      <span className="sr-only">
        Step {index + 1}, {step.title}, {stateWord}
      </span>
      <svg
        width={SVG_W}
        height={CHIP_H}
        viewBox={`0 0 ${SVG_W} ${CHIP_H}`}
        className="absolute left-0 top-0 overflow-visible"
        aria-hidden
        focusable="false"
      >
        {/* the socket outline — always present; an empty dashed one IS the
            visible gap that shows a step hasn't joined yet */}
        <path
          d={FULL_D}
          fill="none"
          stroke="var(--border)"
          strokeWidth={1}
          strokeDasharray={status === "complete" ? undefined : "3 3"}
        />
        {showChip ? (
          <g
            data-dovetail-seated={
              status === "complete" && !animating ? "true" : undefined
            }
            className={
              animPhase === "seat"
                ? "ns-dove-seat"
                : animPhase === "reject"
                  ? "ns-dove-reject"
                  : undefined
            }
            style={animating ? travelStyle : undefined}
          >
            <path d={FULL_D} fill="var(--surface)" stroke="var(--border)" strokeWidth={1} />
          </g>
        ) : null}
        {isCurrent ? (
          <path
            data-current-pin
            d={LEFT_EDGE_D}
            fill="none"
            stroke="var(--border)"
            strokeWidth={1}
            className="ns-dove-pin"
          />
        ) : null}
      </svg>
      <span className="pointer-events-none absolute inset-0 flex items-center justify-center truncate px-2 text-center font-mono text-[10px] tracking-wide text-foreground">
        {step.title}
      </span>
    </li>
  );
}

export function DovetailRun({
  steps,
  defaultValues,
  submitLabel = "Submit",
  onComplete,
  className = "",
}: DovetailRunProps) {
  const uid = useId();
  const reduced = useReducedMotion();
  const [currentIndex, setCurrentIndex] = useState(0);
  const [values, setValues] = useState<Record<string, string>>(
    () => defaultValues ?? {}
  );
  const [stepStatus, setStepStatus] = useState<StepStatus[]>(() =>
    steps.map(() => "pending")
  );
  const [animPhase, setAnimPhase] = useState<Record<number, AnimPhase>>({});
  const [errors, setErrors] = useState<Record<string, string>>({});
  const [done, setDone] = useState(false);

  const fieldRefs = useRef<Record<string, HTMLInputElement | null>>({});
  const timeouts = useRef<number[]>([]);
  const prevIndexRef = useRef(currentIndex);

  useEffect(
    () => () => {
      timeouts.current.forEach((t) => window.clearTimeout(t));
    },
    []
  );

  // focus the new step's first field when Next actually advances — never on
  // first mount, and never on Back (a deliberate return needs no yank).
  useEffect(() => {
    if (prevIndexRef.current !== currentIndex && currentIndex > prevIndexRef.current) {
      const first = steps[currentIndex]?.fields[0];
      if (first) fieldRefs.current[first.id]?.focus();
    }
    prevIndexRef.current = currentIndex;
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [currentIndex]);

  function playAnim(index: number, phase: "seat" | "reject") {
    if (reduced) return;
    setAnimPhase((p) => ({ ...p, [index]: phase }));
    const ms = phase === "seat" ? SEAT_MS : REJECT_MS;
    const t = window.setTimeout(() => {
      setAnimPhase((p) => ({ ...p, [index]: "idle" }));
    }, ms);
    timeouts.current.push(t);
  }

  function validateStep(step: DovetailStep, vals: Record<string, string>) {
    const next: Record<string, string> = {};
    for (const field of step.fields) {
      const value = (vals[field.id] ?? "").trim();
      if (field.required && !value) {
        next[field.id] = `${field.label} is required.`;
        continue;
      }
      if (value && field.type === "email" && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
        next[field.id] = `Enter a valid ${field.label.toLowerCase()}.`;
        continue;
      }
      if (value && field.validate) {
        const msg = field.validate(value);
        if (msg) next[field.id] = msg;
      }
    }
    return next;
  }

  function handleFieldChange(id: string, value: string) {
    setValues((v) => ({ ...v, [id]: value }));
    setErrors((e) => {
      if (!(id in e)) return e;
      const { [id]: _drop, ...rest } = e;
      return rest;
    });
  }

  function handleSubmitStep(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    if ((animPhase[currentIndex] ?? "idle") !== "idle") return;

    const step = steps[currentIndex];
    const stepErrors = validateStep(step, values);

    if (Object.keys(stepErrors).length > 0) {
      setErrors(stepErrors);
      if (stepStatus[currentIndex] === "complete") {
        setStepStatus((s) => s.map((v, i) => (i === currentIndex ? "pending" : v)));
      }
      playAnim(currentIndex, "reject");
      const firstInvalid = step.fields.find((f) => stepErrors[f.id]);
      if (firstInvalid) fieldRefs.current[firstInvalid.id]?.focus();
      return;
    }

    setErrors({});
    setStepStatus((s) => s.map((v, i) => (i === currentIndex ? "complete" : v)));
    playAnim(currentIndex, "seat");

    if (currentIndex === steps.length - 1) {
      setDone(true);
      onComplete?.(values);
    } else {
      setCurrentIndex((i) => i + 1);
    }
  }

  function goBack() {
    if (currentIndex === 0) return;
    setErrors({});
    setDone(false);
    setCurrentIndex((i) => i - 1);
  }

  const step = steps[currentIndex];
  const errorEntries = Object.entries(errors);
  const errorId = `${uid}-errors`;

  return (
    <div className={className}>
      <style>{`
@keyframes ns-dove-seat {
  0%   { transform: translateX(var(--travel)); opacity: 0; animation-timing-function: cubic-bezier(0.16,1,0.3,1); }
  18%  { opacity: 1; }
  72%  { transform: translateX(12px); animation-timing-function: cubic-bezier(0.34,1.56,0.64,1); }
  100% { transform: translateX(0); opacity: 1; }
}
@keyframes ns-dove-reject {
  0%   { transform: translateX(var(--travel)); opacity: 0; animation-timing-function: cubic-bezier(0.16,1,0.3,1); }
  15%  { opacity: 1; }
  55%  { transform: translateX(0px); animation-timing-function: linear; }
  62%  { transform: translateX(4px); }
  69%  { transform: translateX(-1.5px); }
  76%  { transform: translateX(2px); }
  83%  { transform: translateX(0px); animation-timing-function: cubic-bezier(0.4,0,1,1); }
  92%  { opacity: 1; }
  100% { transform: translateX(var(--travel)); opacity: 0; }
}
.ns-dove-seat { animation: ns-dove-seat ${SEAT_MS}ms both; }
.ns-dove-reject { animation: ns-dove-reject ${REJECT_MS}ms both; }
.ns-dove-root:focus-within [data-current-pin] { stroke: var(--ns-accent); }
@media (prefers-reduced-motion: reduce) {
  .ns-dove-seat, .ns-dove-reject { animation: none; }
}
`}</style>

      <div className="ns-dove-root rounded-md border border-border bg-background p-4">
        {/* the rail — decorative summary of the joined run; a real list, but
            no interactive control lives inside it, so Tab never enters it */}
        <ol aria-label="Steps" className="mb-5 flex gap-0 overflow-x-auto pb-1">
          {steps.map((s, i) => (
            <RailSlot
              key={s.id}
              step={s}
              index={i}
              status={stepStatus[i]}
              isCurrent={i === currentIndex}
              animPhase={animPhase[i] ?? "idle"}
            />
          ))}
        </ol>

        {done ? (
          <div role="status" className="rounded-sm border border-border bg-surface p-4">
            <p className="font-mono text-xs tracking-wide text-ns-muted">ALL STEPS JOINED</p>
            <p className="mt-1 text-sm text-foreground">
              Every step seated cleanly — the run is complete.
            </p>
          </div>
        ) : (
          <form onSubmit={handleSubmitStep} noValidate>
            <fieldset>
              <legend className="text-sm font-semibold text-foreground">{step.title}</legend>

              {errorEntries.length > 0 ? (
                <p
                  id={errorId}
                  role="alert"
                  aria-live="assertive"
                  data-dovetail-errors
                  className="mt-3 rounded-sm border border-border bg-surface px-3 py-2 font-mono text-xs text-foreground"
                >
                  {errorEntries.length === 1
                    ? "1 error: "
                    : `${errorEntries.length} errors: `}
                  {errorEntries.map(([, msg]) => msg).join(" ")}
                </p>
              ) : null}

              <div className="mt-3 space-y-3">
                {step.fields.map((field) => {
                  const inputId = `${uid}-${step.id}-${field.id}`;
                  const fieldErrorId = `${inputId}-error`;
                  const hasError = Boolean(errors[field.id]);
                  return (
                    <div key={field.id}>
                      <label
                        htmlFor={inputId}
                        className="block font-mono text-[11px] uppercase tracking-wide text-ns-muted"
                      >
                        {field.label}
                        {field.required ? " *" : ""}
                      </label>
                      <input
                        id={inputId}
                        ref={(el) => {
                          fieldRefs.current[field.id] = el;
                        }}
                        type={field.type ?? "text"}
                        value={values[field.id] ?? ""}
                        placeholder={field.placeholder}
                        autoComplete={field.autoComplete}
                        aria-invalid={hasError || undefined}
                        aria-describedby={hasError ? fieldErrorId : undefined}
                        onChange={(e) => handleFieldChange(field.id, e.target.value)}
                        className={
                          "mt-1 w-full rounded-sm border bg-surface px-2.5 py-1.5 text-sm text-foreground outline-none transition-colors focus-visible:border-ns-accent " +
                          (hasError ? "border-foreground" : "border-border")
                        }
                      />
                      {hasError ? (
                        <p id={fieldErrorId} className="mt-1 font-mono text-xs text-ns-muted">
                          {errors[field.id]}
                        </p>
                      ) : null}
                    </div>
                  );
                })}
              </div>

              <div className="mt-4 flex items-center justify-end gap-2">
                {currentIndex > 0 ? (
                  <button
                    type="button"
                    onClick={goBack}
                    className="inline-flex items-center rounded-sm border border-border px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-surface focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent"
                  >
                    Back
                  </button>
                ) : null}
                <button
                  type="submit"
                  data-dovetail-next
                  className="inline-flex items-center rounded-sm bg-ns-accent px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-ns-accent-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent"
                >
                  {currentIndex === steps.length - 1 ? submitLabel : "Next"}
                </button>
              </div>
            </fieldset>
          </form>
        )}
      </div>
    </div>
  );
}

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

Use when a checkout, onboarding, or config wizard where each step's completion needs to read as a trustworthy, physically locked-in commitment rather than a checkmark row — pick progress-narrated instead for a determinate progress bar that narrates phases as it fills, which advances continuously rather than gating on a mechanical, validity-tested join per discrete step.

Install

npx shadcn add https://design.helpmarq.com/r/wizard-dovetail.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 multi-step form (`steps`: {id, title, fields}[], each field {id, label, type, required, placeholder, autoComplete, validate}) where only one step's fields are interactive at a time — a real `<fieldset>`/`<legend>` region per step — while a rail above it accumulates a physical summary of every completed step. Each rail slot is a fixed-size SVG shape (104x34) cut with dovetail edges: a flared trapezoid tail protrudes past its trailing (right) edge, and an identical trapezoid notch is cut into its leading (left) edge as a socket, both drawn from one shared path-generator so an adjacent slot's tail would nest exactly into this one's socket — the socket is DEEPER-wide than its mouth (the classic dovetail profile), which is what makes the joint read as something that resists being pulled straight apart rather than a plain rounded chip. Every slot always renders its socket outline (1px --border stroke, dashed while not yet joined) as a constant, visible 'not yet joined' gap; a step's fields validating on Next/Submit fills that slot with a solid --surface chip animated in via a CSS keyframe (translateX from 40px to 0 plus a fade-in) — ease-out-expo for the bulk of the travel, then the final 12px settles on a spring-shaped cubic-bezier(0.34,1.56,0.64,1) with a small overshoot past the seat before resting, so the joint visibly mates; on failed validation the same chip flies in, decelerates to the joint, then recoils off it — a decaying two-swing 4px bounce — and fades back out to its start offset, leaving the socket empty/dashed exactly where the outstanding field is. A step's status can regress: editing an already-seated step's fields back into invalidity and resubmitting un-seats its chip (back to the dashed socket) before the same reject animation plays — the rail never shows a joint that isn't currently true. The step currently being worked has its rail slot's pin (left/socket) edge redrawn on top in --ns-accent, but ONLY while focus is genuinely inside the form (a `:focus-within` rule scoped to a shared wrapper around both the rail and the fieldset) — moving focus elsewhere reverts it to --border, so the accent never reads as a static label. On invalid submit, a `role="alert" aria-live="assertive"` summary above the fields lists every failing field's message, each field also gets its own error paragraph directly below it via `aria-describedby` plus `aria-invalid`, and focus moves straight to the first invalid field — the live summary and the moved focus fire together, not gated behind the ~700ms bounce animation finishing. Next and Back are real buttons (`type="submit"` — the form has `noValidate` and does its own validation on submit so Enter in any field submits the step honestly; Back is `type="button"` and only shown past the first step, never validates, never un-seats a step just from viewing it). The rail is one `<ol aria-label="Steps">`; each `<li>` carries `aria-current="step"` only for the active index and a visually-hidden 'Step N, {title}, {complete|current|not started}' string — the SVG itself is `aria-hidden`, so the rail's only tab stops are none: it contributes zero interactive elements and tab order flows straight from one field to the next to Back/Next, never detouring into the decorative rail. The last step's button label is the `submitLabel` prop (default 'Submit'); completing it replaces the fieldset with a plain resting confirmation panel and calls `onComplete(values)`, while the rail keeps showing every seated chip. Under prefers-reduced-motion the seat/reject keyframes are stripped entirely (checked via matchMedia with a change listener, plus a CSS media-query belt-and-suspenders) — a step's chip appears already seated or the socket stays empty, immediately, with no travel, bounce, or fade, fully legible and usable either way. Every color is `var(--background|--surface|--border|--foreground|--ns-muted|--ns-accent)`, zero hex, both themes render; zero dependencies, DOM + SVG + CSS only, no canvas. Differs from progress-narrated (a determinate progress bar whose fill narrates continuous phase completion with a typed caption and a milestone ledger docking below the track) by being validity-gated and discrete rather than continuous: wizard-dovetail's steps don't advance a percentage, they either mechanically join the rail or get physically rejected back to the form with the gap left exactly where the invalid field is. Differs from drill-down-spines (drill-down navigation where a pushed level compresses into a permanently clickable spine you can pop back into) by having no navigation history to click back into — the rail is a read-only, non-interactive summary of validity, not a set of live route targets.

Props

PropTypeDefaultDescription
stepsDovetailStep[]—the wizard steps, in order
defaultValues?Record<string, string>—seed field values by field id, e.g. to demonstrate a pre-filled step
submitLabel?string"Submit"label for the final step's submit button
onComplete?(values: Record<string, string>) => void—called with all field values, keyed by field id, once the final step submits
className?string—extra classes merged onto the rendered root element