Skip to content
Star on GitHub
ProductStepper
Style
Accent
Sound

Built by DesignMe, free to use under the MIT licence.

Expert design and dev support for startups and enterprises.

Book a call

Stepper

A multi-step setup with a progress track that fills as you go. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.

$npx designme add stepper
01

Install

Add it to your project with the CLI.

HTML, CSS and JavaScript

$npx designme add stepper

React

$npx designme add stepper --react

The CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro.

02

About

What it is for and what it does.

Stepper walks a person through a short setup one question at a time, with a track of numbered steps that fills in as they go. Each step offers a few choices, such as how much freedom the agent should have, or your own fields. Finishing shows a confirmation that sums up the picks, with a short burst of confetti.

  • When setting up an agent needs a few decisions, like how it works and what it connects to
  • When each question is simple enough to answer with one of a few options
  • When people should be able to jump back to an earlier step to change an answer
  • When a step needs a server check before the person can move on
03

Usage

Import it and drive it from your own state.

Import

import { Stepper } from '@/components/designme/stepper/Stepper';

Example

The demo above as a React component, 14 lines you can copy.

'use client';
// The preview from designme.dev, rebuilt from the React component: pick an option on each step, go back from the track, start over at the end.
import { Stepper, type StepperStep } from './Stepper';

const STEPS: StepperStep[] = [
  { label: 'Workspace', title: 'Name your workspace', description: 'This is what your team will see.', options: [{ label: 'Studio', detail: 'Small team' }, { label: 'Agency', detail: 'Many clients' }, { label: 'In-house', detail: 'One product' }] },
  { label: 'Agent', title: 'How should the agent work?', description: 'You can change this any time.', defaultOption: 1, options: [{ label: 'Ask first', detail: 'Safest' }, { label: 'Balanced', detail: 'Recommended' }, { label: 'Autopilot', detail: 'Fastest' }] },
  { label: 'Connect', title: 'Where do your designs live?', description: 'Pick one to start.', defaultOption: 2, options: [{ label: 'Figma', detail: 'Frames and tokens' }, { label: 'GitHub', detail: 'Code and PRs' }, { label: 'Both', detail: 'Full loop' }] }
];

export default function StepperDemo() {
  return <Stepper steps={STEPS} />;
}
04

Props

Everything Stepper takes.

PropTypeWhat it does
stepsrequiredStepperStep[]
stepnumberThe step on screen, from 0. steps.length shows the finished screen. Leave it out to let Back and Continue move it
defaultStepnumber
onStepChange(step: number) => void
valuesnumber[]The picked option of each step, by index
defaultValuesnumber[]
onValuesChange(values: number[]) => void
onNext(step: number, values: number[]) => boolean | void | Promise<boolean | void>Called on Continue or Finish before moving on. Return false (or a promise of false) to stay on the step, for example to show an error
onFinish(values: number[]) => voidCalled with the picks when the last step is finished
doneTitlestringThe finished screen's heading, like "You're set up"
doneDetailReactNodeThe line under it. Defaults to the picked options, joined with dots
backLabelstring
nextLabelstring
finishLabelstring
restartLabelstring
barebooleanSits flat in a chat thread (the default, as on designme.dev). Set false to give it a card of its own
classNamestring
05

Code

The full source, as the CLI installs it.

Stepper.tsxReact · 159 lines
'use client';
import { useEffect, useLayoutEffect, useRef, useState, type KeyboardEvent, type ReactNode } from 'react';
import { cx } from '../lib/cx';
import { useControllable, useReducedMotion } from '../lib/hooks';
import { Icon } from '../lib/icons';
import { enter } from '../lib/motion';
import '../designme.css';
import './stepper.css';

export interface StepperOption {
  /** Like "Balanced" */
  label: string;
  /** The quiet line under it, like "Recommended" */
  detail?: string;
}

export interface StepperStep {
  /** The short name on the track, like "Agent" */
  label: string;
  /** The question at the top of the step, like "How should the agent work?" */
  title: string;
  /** The quiet line under the title */
  description?: string;
  /** The choices, one of which is picked. Leave them out and pass `content` for a step of your own */
  options?: StepperOption[];
  /** Your own fields for this step, shown under the title in place of the options */
  content?: ReactNode;
  /** The option picked to begin with. Defaults to the first */
  defaultOption?: number;
}

export interface StepperProps {
  steps: StepperStep[];
  /** The step on screen, from 0. steps.length shows the finished screen. Leave it out to let Back and Continue move it */
  step?: number;
  defaultStep?: number;
  onStepChange?: (step: number) => void;
  /** The picked option of each step, by index */
  values?: number[];
  defaultValues?: number[];
  onValuesChange?: (values: number[]) => void;
  /** Called on Continue or Finish before moving on. Return false (or a promise of false) to stay on the step, for example to show an error */
  onNext?: (step: number, values: number[]) => boolean | void | Promise<boolean | void>;
  /** Called with the picks when the last step is finished */
  onFinish?: (values: number[]) => void;
  /** The finished screen's heading, like "You're set up" */
  doneTitle?: string;
  /** The line under it. Defaults to the picked options, joined with dots */
  doneDetail?: ReactNode;
  backLabel?: string;
  nextLabel?: string;
  finishLabel?: string;
  restartLabel?: string;
  /** Sits flat in a chat thread (the default, as on designme.dev). Set false to give it a card of its own */
  bare?: boolean;
  className?: string;
}

/** A short burst of confetti from an element, in the accent and a few brand colours, falling as it fades. Returns its cleanup */
function burst(at: HTMLElement) {
  const bp = getComputedStyle(at).getPropertyValue('--dm-motion').trim() === 'bp';
  const box = document.createElement('span'); box.className = 'dm-stx-conf'; at.appendChild(box);
  const COL = ['var(--dm-accent)', 'var(--dm-ok)', 'var(--dm-c2)', 'var(--dm-c4)', 'var(--dm-c5)', 'var(--dm-c6)'], N = 22;
  for (let k = 0; k < N; k++) {
    const p = document.createElement('i'), round = k % 3 === 0, a = -Math.PI / 2 + (k / (N - 1) - .5) * Math.PI * 1.5 + Math.sin(k * 7.3) * .18;
    const d = 46 + (k * 37 % 30), w = round ? 5 : 3 + k % 2, h = round ? 5 : 7 + k % 3;
    if (round) p.className = 'dm-d';
    Object.assign(p.style, { width: w + 'px', height: h + 'px', marginLeft: -w / 2 + 'px', marginTop: -h / 2 + 'px', background: COL[k % COL.length] });
    box.appendChild(p);
    const x = Math.cos(a) * d, y = Math.sin(a) * d, r0 = k * 47 % 180, spin = (k % 2 ? 1 : -1) * (180 + k * 23 % 200), ms = (bp ? 760 : 1150) + k * 13 % 220;
    p.animate([
      { opacity: 1, transform: `translate(0,0) rotate(${r0}deg) scale(.6)` },
      { opacity: 1, transform: `translate(${x}px,${y}px) rotate(${r0 + spin * .5}deg) scale(1)`, offset: .42 },
      { opacity: 0, transform: `translate(${x * 1.18}px,${y + 46}px) rotate(${r0 + spin}deg) scale(.9)` }
    ], { duration: ms, delay: 120 + k % 4 * 18, easing: bp ? 'cubic-bezier(.65,0,.25,1)' : 'cubic-bezier(.16,1,.3,1)', fill: 'both' });
  }
  const t = setTimeout(() => box.remove(), 1700);
  return () => { clearTimeout(t); box.remove(); };
}

/** A few steps of setup, one question at a time, with a track that fills in as you go. */
export function Stepper({ steps, step, defaultStep = 0, onStepChange, values, defaultValues, onValuesChange, onNext, onFinish,
  doneTitle = "You're set up", doneDetail, backLabel = 'Back', nextLabel = 'Continue', finishLabel = 'Finish', restartLabel = 'Start over', bare = true, className }: StepperProps) {
  const [cur, setCur] = useControllable(step, defaultStep, onStepChange);
  const [picks, setPicks] = useControllable(values, defaultValues ?? steps.map(s => s.defaultOption ?? 0), onValuesChange);
  // each move slides the new step in from the side it comes from; the first step just appears
  const [seen, setSeen] = useState({ step: cur, dir: 0, key: 0 });
  if (seen.step !== cur) setSeen({ step: cur, dir: cur > seen.step ? 1 : -1, key: seen.key + 1 });
  const [busy, setBusy] = useState(false);
  const finished = cur >= steps.length;

  const next = async () => {
    if (finished) { setCur(0); return; }
    if (onNext) {
      setBusy(true);
      try { if ((await onNext(cur, picks)) === false) return; } finally { setBusy(false); }
    }
    if (cur === steps.length - 1) onFinish?.(picks);
    setCur(cur + 1);
  };
  const pick = (i: number, k: number) => setPicks(steps.map((_, j) => (j === i ? k : picks[j] ?? 0)));
  const s = steps[cur];
  // arriving at the finished screen from Finish is the moment to celebrate
  const celebrate = finished && seen.dir > 0;
  const ok = useRef<HTMLSpanElement>(null);
  const reduce = useReducedMotion();
  // the finished screen drops the track and keeps the height the steps had, centred in it
  const root = useRef<HTMLDivElement>(null), tall = useRef(0);
  useLayoutEffect(() => { if (!finished && root.current) tall.current = root.current.offsetHeight; });
  useEffect(() => { if (celebrate && !reduce && ok.current) return burst(ok.current); }, [celebrate, seen.key, reduce]);

  return (
    <div data-dm="" ref={root} className={cx('dm-stx', !bare && 'dm-panel', finished && 'dm-fin', finished && tall.current > 0 && 'dm-held', className)} style={finished && tall.current ? { minHeight: tall.current } : undefined}>
      <div className="dm-stx-track">
        {steps.map((st, i) => [
          // a finished step on the track takes you back to it
          <div key={'s' + i} className={cx('dm-stx-st', i === cur && 'dm-on', i < cur && 'dm-done')} aria-current={i === cur ? 'step' : undefined}
            {...(i < cur && { role: 'button', tabIndex: 0, onClick: () => setCur(i), onKeyDown: (e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setCur(i); } } })}>
            <span className="dm-n">{i < cur ? <Icon name="check" size="xs" /> : i + 1}</span><span className="dm-lb">{st.label}</span>
          </div>,
          i < steps.length - 1 && <div key={'l' + i} className={cx('dm-stx-ln', i < cur && 'dm-full')}><i /></div>
        ])}
      </div>
      <div className="dm-stx-vp">
        {/* each step slides in from the side it comes from; the finished screen stays put and celebrates instead */}
        <div key={seen.key} className={cx('dm-stx-body', !finished && seen.dir > 0 && 'dm-inr', !finished && seen.dir < 0 && 'dm-inl')}>
          {finished ? (
            <div className={cx('dm-stx-dn', celebrate && 'dm-go')}>
              <span ref={ok} className="dm-stx-ok">
                <svg viewBox="0 0 44 44" aria-hidden="true"><circle className="dm-c" cx="22" cy="22" r="20" /><path className="dm-k" d="M14 22.5l5.5 5.5L30.5 16.5" /></svg>
              </span>
              <b {...(celebrate ? enter(260) : {})}>{doneTitle}</b>
              <span {...(celebrate ? enter(340) : {})} className={cx('dm-muted', celebrate && enter(340).className)}>{doneDetail ?? steps.map((st, k) => st.options?.[picks[k] ?? 0]?.label).filter(Boolean).join(' · ')}</span>
            </div>
          ) : (
            <>
              <h4>{s.title}</h4>
              {s.description && <p>{s.description}</p>}
              {s.content ?? (s.options && (
                <div className="dm-stx-opts" role="radiogroup" aria-label={s.title}>
                  {s.options.map((o, k) => (
                    <button type="button" key={k} role="radio" aria-checked={picks[cur] === k} className={cx('dm-stx-o', picks[cur] === k && 'dm-on')} onClick={() => pick(cur, k)}>
                      <b style={{ fontWeight: 500 }}>{o.label}</b>{o.detail && <span>{o.detail}</span>}
                    </button>
                  ))}
                </div>
              ))}
            </>
          )}
        </div>
      </div>
      <div className="dm-stx-f">
        <button type="button" className="dm-pill dm-ghost dm-sm dm-stx-bk" disabled={cur === 0} onClick={() => setCur(Math.max(0, cur - 1))}>{backLabel}</button>
        <button type="button" className="dm-pill dm-primary dm-sm dm-stx-nx" disabled={busy} onClick={next}>{finished ? restartLabel : cur === steps.length - 1 ? finishLabel : nextLabel}</button>
      </div>
    </div>
  );
}
stepper.cssReact · 60 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Stepper ---------- */
/* the agent walks you through setup in the chat: no container; with .panel it becomes a card */
.dm-stx{padding:var(--dm-s-4) 0;display:flex;flex-direction:column;gap:var(--dm-s-4)}
.dm-stx.dm-panel{padding:var(--dm-s-4)}
.dm-stx-track{display:flex;align-items:center;gap:0}
.dm-stx-st{display:flex;align-items:center;gap:var(--dm-s-2);font-size:12.5px;color:var(--dm-muted);white-space:nowrap;transition:color .3s}
.dm-stx-st .dm-n{width:24px;height:24px;border-radius:50%;display:grid;place-items:center;font-size:11.5px;font-weight:500;box-shadow:inset 0 0 0 1.5px color-mix(in srgb,var(--dm-line) 50%,transparent);color:var(--dm-muted);transition:background .35s var(--dm-ease),box-shadow .35s,color .35s;flex:none}
.dm-stx-st.dm-on{color:var(--dm-fg)}
.dm-stx-st.dm-on .dm-n{box-shadow:inset 0 0 0 1.5px var(--dm-accent-mark,var(--dm-accent)),0 0 0 4px var(--dm-accent-bg);color:var(--dm-accent-fg,var(--dm-accent))}
.dm-stx-st.dm-done{color:var(--dm-fg2);cursor:pointer}
.dm-stx-st.dm-done .dm-n{background:var(--dm-accent);box-shadow:none;color:var(--dm-on-accent)}
.dm-stx-ln{flex:1;height:2px;margin:0 10px;border-radius:1px;background:var(--dm-line);position:relative;overflow:hidden;min-width:16px}
.dm-stx-ln i{position:absolute;inset:0;background:var(--dm-accent-mark,var(--dm-accent));transform:scaleX(0);transform-origin:left;transition:transform .5s var(--dm-ease)}
.dm-stx-ln.dm-full i{transform:none}
@media (max-width:520px){.dm-stx-st .dm-lb{display:none}.dm-stx-st.dm-on .dm-lb{display:inline}}
/* each step sits in the middle, the same distance from the track above and the buttons below */
.dm-stx-vp{overflow-x:clip;overflow-y:visible;min-height:150px;display:flex;flex-direction:column;justify-content:center}
.dm-stx-body{display:flex;flex-direction:column;gap:10px}
.dm-stx-body.dm-inr{animation:dm-stxr .45s var(--dm-ease) both}.dm-stx-body.dm-inl{animation:dm-stxl .45s var(--dm-ease) both}
@keyframes dm-stxr{from{opacity:0;transform:translateX(24px);filter:blur(4px)}}
@keyframes dm-stxl{from{opacity:0;transform:translateX(-24px);filter:blur(4px)}}
.dm-stx-body h4{margin:0;font-size:14px;font-weight:500}
.dm-stx-body p{margin:0;font-size:12.5px;color:var(--dm-muted)}
.dm-stx-opts{display:grid;grid-template-columns:repeat(3,1fr);gap:var(--dm-s-2)}
@media (max-width:520px){.dm-stx-opts{grid-template-columns:1fr}}
.dm-stx-o{padding:var(--dm-s-3);border-radius:var(--dm-r-in);background:var(--dm-chip);text-align:left;font-size:13px;display:flex;flex-direction:column;gap:var(--dm-s-1);transition:background .2s,box-shadow .25s}
.dm-stx-o span{font-size:11.5px;color:var(--dm-muted)}
@media (hover:hover) and (pointer:fine){.dm-stx-o:hover{background:var(--dm-hover)}}
/* the options fill the sliding view, which would cut an outside ring: theirs is drawn inside */
.dm-stx-o:focus-visible{outline-offset:-2px}
/* the options are grey tiles with no edge; the picked one turns white with a dimmed grey edge */
.dm-stx-o.dm-on{background:var(--dm-surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-pick-line) 50%,transparent),0 1px 2px rgba(0,0,0,.04)}
@media (hover:hover) and (pointer:fine){.dm-stx-o.dm-on:hover{background:var(--dm-surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-pick-line) 50%,transparent),0 1px 2px rgba(0,0,0,.04)}}
.dm-stx-f{display:flex;justify-content:space-between}
/* once it's done there is nothing to go back to: Start over sits alone in the middle */
.dm-stx.dm-fin .dm-stx-f{justify-content:center}
.dm-stx.dm-fin .dm-stx-bk{display:none}
/* finished: the track has done its job, so only the success shows, centred in the height the steps had */
.dm-stx.dm-fin .dm-stx-track{display:none}
.dm-stx.dm-fin .dm-stx-vp{min-height:0}.dm-stx.dm-fin .dm-stx-dn{padding:0 0 var(--dm-s-2)}
.dm-stx-bk[disabled]{visibility:hidden}
.dm-stx-dn{display:flex;flex-direction:column;align-items:center;gap:6px;padding:18px 0;text-align:center;font-size:13px}
.dm-stx-dn>b{font-size:14px;font-weight:500}
/* finished: the check pops and draws itself, a soft ring spreads from it, and a little confetti bursts out once */
.dm-stx-ok{position:relative;width:44px;height:44px;margin-bottom:6px}
.dm-stx-ok svg{display:block;width:44px;height:44px;overflow:visible}
.dm-stx-ok .dm-c{fill:var(--dm-accent-mark,var(--dm-accent));transform-origin:center;transform-box:fill-box}
.dm-stx-ok .dm-k{fill:none;stroke:var(--dm-on-accent);stroke-width:3;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:24}
.dm-stx-ok::after{content:"";position:absolute;inset:0;border-radius:50%;pointer-events:none}
.dm-stx-dn.dm-go .dm-stx-ok .dm-c{animation:dm-stxpop .6s var(--dm-ease) both}
.dm-stx-dn.dm-go .dm-stx-ok .dm-k{animation:dm-stxdraw .42s var(--dm-ease) .26s both}
.dm-stx-dn.dm-go .dm-stx-ok::after{animation:dm-stxring 1.2s var(--dm-ease) .18s}
@keyframes dm-stxpop{from{transform:scale(.3);opacity:0}60%{transform:scale(1.08);opacity:1}to{transform:none;opacity:1}}
@keyframes dm-stxdraw{from{stroke-dashoffset:24}to{stroke-dashoffset:0}}
@keyframes dm-stxring{from{box-shadow:0 0 0 0 color-mix(in srgb,var(--dm-accent-mark,var(--dm-accent)) 40%,transparent)}to{box-shadow:0 0 0 20px color-mix(in srgb,var(--dm-accent-mark,var(--dm-accent)) 0%,transparent)}}
.dm-stx-conf{position:absolute;left:50%;top:50%;width:0;height:0;pointer-events:none}
.dm-stx-conf i{position:absolute;left:0;top:0;border-radius:2px;opacity:0}
.dm-stx-conf i.dm-d{border-radius:50%}
stepper.htmlHTML · 10 lines
<div class="card bare" data-c="stepper">
  <div class="stage-in">
    <div class="stx">
      <div class="stx-track"></div>
      <div class="stx-vp"><div class="stx-body"></div></div>
      <div class="stx-f"><button class="pill ghost sm stx-bk">Back</button><button class="pill primary sm stx-nx">Continue</button></div>
    </div>
  </div>
</div>
stepper.cssHTML · 59 lines
/* ---------- Stepper ---------- */
/* the agent walks you through setup in the chat: no container; with .panel it becomes a card */
.stx{padding:var(--s-4) 0;display:flex;flex-direction:column;gap:var(--s-4)}
.stx.panel{padding:var(--s-4)}
.stx-track{display:flex;align-items:center;gap:0}
.stx-st{display:flex;align-items:center;gap:var(--s-2);font-size:12.5px;color:var(--muted);white-space:nowrap;transition:color .3s}
.stx-st .n{width:24px;height:24px;border-radius:50%;display:grid;place-items:center;font-size:11.5px;font-weight:500;box-shadow:inset 0 0 0 1.5px color-mix(in srgb,var(--line) 50%,transparent);color:var(--muted);transition:background .35s var(--ease),box-shadow .35s,color .35s;flex:none}
.stx-st.on{color:var(--fg)}
.stx-st.on .n{box-shadow:inset 0 0 0 1.5px var(--accent-mark,var(--accent)),0 0 0 4px var(--accent-bg);color:var(--accent-fg,var(--accent))}
.stx-st.done{color:var(--fg2);cursor:pointer}
.stx-st.done .n{background:var(--accent);box-shadow:none;color:var(--on-accent)}
.stx-ln{flex:1;height:2px;margin:0 10px;border-radius:1px;background:var(--line);position:relative;overflow:hidden;min-width:16px}
.stx-ln i{position:absolute;inset:0;background:var(--accent-mark,var(--accent));transform:scaleX(0);transform-origin:left;transition:transform .5s var(--ease)}
.stx-ln.full i{transform:none}
@media (max-width:520px){.stx-st .lb{display:none}.stx-st.on .lb{display:inline}}
/* each step sits in the middle, the same distance from the track above and the buttons below */
.stx-vp{overflow-x:clip;overflow-y:visible;min-height:150px;display:flex;flex-direction:column;justify-content:center}
.stx-body{display:flex;flex-direction:column;gap:10px}
.stx-body.inr{animation:stxr .45s var(--ease) both}.stx-body.inl{animation:stxl .45s var(--ease) both}
@keyframes stxr{from{opacity:0;transform:translateX(24px);filter:blur(4px)}}
@keyframes stxl{from{opacity:0;transform:translateX(-24px);filter:blur(4px)}}
.stx-body h4{margin:0;font-size:14px;font-weight:500}
.stx-body p{margin:0;font-size:12.5px;color:var(--muted)}
.stx-opts{display:grid;grid-template-columns:repeat(3,1fr);gap:var(--s-2)}
@media (max-width:520px){.stx-opts{grid-template-columns:1fr}}
.stx-o{padding:var(--s-3);border-radius:var(--r-in);background:var(--chip);text-align:left;font-size:13px;display:flex;flex-direction:column;gap:var(--s-1);transition:background .2s,box-shadow .25s}
.stx-o span{font-size:11.5px;color:var(--muted)}
@media (hover:hover) and (pointer:fine){.stx-o:hover{background:var(--hover)}}
/* the options fill the sliding view, which would cut an outside ring: theirs is drawn inside */
.stx-o:focus-visible{outline-offset:-2px}
/* the options are grey tiles with no edge; the picked one turns white with a dimmed grey edge */
.stx-o.on{background:var(--surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--pick-line) 50%,transparent),0 1px 2px rgba(0,0,0,.04)}
@media (hover:hover) and (pointer:fine){.stx-o.on:hover{background:var(--surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--pick-line) 50%,transparent),0 1px 2px rgba(0,0,0,.04)}}
.stx-f{display:flex;justify-content:space-between}
/* once it's done there is nothing to go back to: Start over sits alone in the middle */
.stx.fin .stx-f{justify-content:center}
.stx.fin .stx-bk{display:none}
/* finished: the track has done its job, so only the success shows, centred in the height the steps had */
.stx.fin .stx-track{display:none}
.stx.fin .stx-vp{min-height:0}.stx.fin .stx-dn{padding:0 0 var(--s-2)}
.stx-bk[disabled]{visibility:hidden}
.stx-dn{display:flex;flex-direction:column;align-items:center;gap:6px;padding:18px 0;text-align:center;font-size:13px}
.stx-dn>b{font-size:14px;font-weight:500}
/* finished: the check pops and draws itself, a soft ring spreads from it, and a little confetti bursts out once */
.stx-ok{position:relative;width:44px;height:44px;margin-bottom:6px}
.stx-ok svg{display:block;width:44px;height:44px;overflow:visible}
.stx-ok .c{fill:var(--accent-mark,var(--accent));transform-origin:center;transform-box:fill-box}
.stx-ok .k{fill:none;stroke:var(--on-accent);stroke-width:3;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:24}
.stx-ok::after{content:"";position:absolute;inset:0;border-radius:50%;pointer-events:none}
.stx-dn.go .stx-ok .c{animation:stxpop .6s var(--ease) both}
.stx-dn.go .stx-ok .k{animation:stxdraw .42s var(--ease) .26s both}
.stx-dn.go .stx-ok::after{animation:stxring 1.2s var(--ease) .18s}
@keyframes stxpop{from{transform:scale(.3);opacity:0}60%{transform:scale(1.08);opacity:1}to{transform:none;opacity:1}}
@keyframes stxdraw{from{stroke-dashoffset:24}to{stroke-dashoffset:0}}
@keyframes stxring{from{box-shadow:0 0 0 0 color-mix(in srgb,var(--accent-mark,var(--accent)) 40%,transparent)}to{box-shadow:0 0 0 20px color-mix(in srgb,var(--accent-mark,var(--accent)) 0%,transparent)}}
.stx-conf{position:absolute;left:50%;top:50%;width:0;height:0;pointer-events:none}
.stx-conf i{position:absolute;left:0;top:0;border-radius:2px;opacity:0}
.stx-conf i.d{border-radius:50%}
stepper.jsHTML · 52 lines
DM.def('stepper', (r, c) => {
  const S = [
    ['Workspace','Name your workspace','This is what your team will see.',[['Studio','Small team'],['Agency','Many clients'],['In-house','One product']]],
    ['Agent','How should the agent work?','You can change this any time.',[['Ask first','Safest'],['Balanced','Recommended'],['Autopilot','Fastest']]],
    ['Connect','Where do your designs live?','Pick one to start.',[['Figma','Frames and tokens'],['GitHub','Code and PRs'],['Both','Full loop']]]
  ];
  const stx = $('.stx', r), track = $('.stx-track', r), body = $('.stx-body', r), bk = $('.stx-bk', r), nx = $('.stx-nx', r);
  let cur = 0, pick = [0, 1, 2];
  track.innerHTML = S.map((s, i) => `<div class="stx-st" data-i="${i}"><span class="n">${i + 1}</span><span class="lb">${s[0]}</span></div>` + (i < S.length - 1 ? '<div class="stx-ln"><i></i></div>' : '')).join('');
  const sts = $$('.stx-st', track), lns = $$('.stx-ln', track);
  function show(i, dir){
    cur = i; sts.forEach((s, j) => { s.classList.toggle('on', j === i); s.classList.toggle('done', j < i); $('.n', s).innerHTML = j < i ? I('check','xs') : j + 1; });
    lns.forEach((l, j) => l.classList.toggle('full', j < i));
    if (i >= S.length){
      if (!stx.classList.contains('fin')) hold(stx);
      body.innerHTML = `<div class="stx-dn"><span class="stx-ok"><svg viewBox="0 0 44 44" aria-hidden="true"><circle class="c" cx="22" cy="22" r="20"/><path class="k" d="M14 22.5l5.5 5.5L30.5 16.5"/></svg></span><b>You're set up</b><span class="muted">${S.map((s, k) => s[3][pick[k]][0]).join(' · ')}</span></div>`;
      bk.disabled = true; nx.textContent = 'Start over'; stx.classList.add('fin');
      // arriving here from Finish is the moment to celebrate; the screen itself doesn't slide
      if (dir > 0){ const dn = $('.stx-dn', body); dn.classList.add('go'); enter($('b', dn), 260); enter($('.muted', dn), 340); burst($('.stx-ok', dn)); }
      return;
    }
    else { if (stx.classList.contains('fin')){ stx.classList.remove('fin'); release(stx); } const s = S[i]; body.innerHTML = `<h4>${s[1]}</h4><p>${s[2]}</p><div class="stx-opts">${s[3].map((o, k) => `<button class="stx-o${pick[i] === k ? ' on' : ''}" data-k="${k}"><b style="font-weight:500">${o[0]}</b><span>${o[1]}</span></button>`).join('')}</div>`;
      $$('.stx-o', body).forEach(b => b.onclick = () => { pick[i] = +b.dataset.k; $$('.stx-o', body).forEach(x => x.classList.toggle('on', x === b)); });
      bk.disabled = i === 0; nx.textContent = i === S.length - 1 ? 'Finish' : 'Continue'; }
    if (dir){ body.classList.remove('inr','inl'); void body.offsetWidth; body.classList.add(dir > 0 ? 'inr' : 'inl'); }
  }
  // a short burst of confetti from the check, in the accent and a few brand colours, falling as it fades
  function burst(at){
    if (REDUCE) return;
    const bp = getComputedStyle(r).getPropertyValue('--motion').trim() === 'bp';
    const box = document.createElement('span'); box.className = 'stx-conf'; at.appendChild(box);
    const COL = ['var(--accent-mark,var(--accent))', 'var(--ok)', 'var(--c2)', 'var(--c4)', 'var(--c5)', 'var(--c6)'], N = 22;
    for (let k = 0; k < N; k++){
      const p = document.createElement('i'), round = k % 3 === 0, a = -Math.PI / 2 + (k / (N - 1) - .5) * Math.PI * 1.5 + Math.sin(k * 7.3) * .18;
      const d = 46 + (k * 37 % 30), w = round ? 5 : 3 + k % 2, h = round ? 5 : 7 + k % 3;
      p.className = round ? 'd' : ''; p.style.width = w + 'px'; p.style.height = h + 'px'; p.style.marginLeft = -w / 2 + 'px'; p.style.marginTop = -h / 2 + 'px'; p.style.background = COL[k % COL.length];
      box.appendChild(p);
      const x = Math.cos(a) * d, y = Math.sin(a) * d, r0 = k * 47 % 180, spin = (k % 2 ? 1 : -1) * (180 + k * 23 % 200), ms = (bp ? 760 : 1150) + k * 13 % 220;
      p.animate([
        { opacity:1, transform:`translate(0,0) rotate(${r0}deg) scale(.6)` },
        { opacity:1, transform:`translate(${x}px,${y}px) rotate(${r0 + spin * .5}deg) scale(1)`, offset:.42 },
        { opacity:0, transform:`translate(${x * 1.18}px,${y + 46}px) rotate(${r0 + spin}deg) scale(.9)` }
      ], { duration:ms, delay:120 + k % 4 * 18, easing:bp ? 'cubic-bezier(.65,0,.25,1)' : 'cubic-bezier(.16,1,.3,1)', fill:'both' });
    }
    c.later(1700, () => box.remove());
  }
  nx.onclick = () => cur >= S.length ? show(0, -1) : show(cur + 1, 1);
  bk.onclick = () => show(Math.max(0, cur - 1), -1);
  sts.forEach((s, i) => s.onclick = () => { if (i < cur) show(i, -1); });
  show(0);
});
06

Questions

What people ask about it.

How do I validate a step before the person continues?
Pass onNext(step, values). Return false, or a promise that resolves to false, to stay on the step. Continue is disabled while the promise is pending.
Can a step have my own inputs instead of options?
Yes. Give the step content with your own fields and leave out options. The content shows under the title.
How are the answers given back to me?
values holds the picked option index for each step, and onFinish receives the same array when the last step is finished. Each step starts on its defaultOption, or the first option.
07

More in Product

Pricing, onboarding, setup flows and spending limits for the rest of your product.

Pricing cardsThree plans with a billing switch and a seat slider.
Onboarding checklistFirst steps with a progress ring, and the agent can do some for you.
Set limitA monthly agent budget with a draggable cap and a forecast.
Currency converterShow plan prices in local currency, with a swap and live rate.