Wizard Dovetail

Form

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.

Install
npx shadcn add https://design.helpmarq.com/r/wizard-dovetail.json
Source
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 {
  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;
  onComplete?: (values: Record<string, string>) => void;
  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 --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(--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-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-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-accent " +
                          (hasError ? "border-foreground" : "border-border")
                        }
                      />
                      {hasError ? (
                        <p id={fieldErrorId} className="mt-1 font-mono text-xs text-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-accent"
                  >
                    Back
                  </button>
                ) : null}
                <button
                  type="submit"
                  data-dovetail-next
                  className="inline-flex items-center rounded-sm bg-accent px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-accent-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
                >
                  {currentIndex === steps.length - 1 ? submitLabel : "Next"}
                </button>
              </div>
            </fieldset>
          </form>
        )}
      </div>
    </div>
  );
}
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.

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 --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|--muted|--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.

Tags
formwizardmulti-stepcheckoutonboardingvalidationsvgaccessibility