{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "stepper",
  "title": "Stepper",
  "type": "registry:ui",
  "description": "Multi-step indicator — horizontal or vertical, with completed / current / upcoming states and optional descriptions per step. Use for onboarding wizards and checkout flows.",
  "categories": [
    "navigation"
  ],
  "files": [
    {
      "path": "packages/elements-lit/src/components/stepper/stepper.ts",
      "content": "import { LitElement, css, html, isServer, nothing } from 'lit'\nimport { Check, X, type IconNode } from 'lucide'\nimport { cn } from '../../lib/utils'\nimport { icon } from '../../lib/icon'\nimport { tailwind } from '../../lib/styles'\nimport { ThemeController } from '../../lib/theme'\nimport { stepperIndicatorVariants } from './stepper.variants'\n\nexport type StepperOrientation = 'horizontal' | 'vertical'\nexport type StepperStatus = 'active' | 'completed' | 'pending' | 'error'\nexport type StepperSize = 'sm' | 'default' | 'lg'\n\n/** React's `StepperStep` config. `icon` is a `lucide` IconNode; `iconName` (kebab-case) adds the `lucide-<name>` class. */\nexport interface StepperStepConfig {\n  id: string | number\n  title: string\n  description?: string\n  icon?: IconNode\n  iconName?: string\n  disabled?: boolean\n  error?: boolean\n}\n\n// Indicator row must match stepperIndicatorVariants sizes (sm 7 / default 9 / lg 11).\nconst indicatorAxisClasses = {\n  horizontal: {\n    sm: 'h-7 w-full items-center justify-center',\n    default: 'h-9 w-full items-center justify-center',\n    lg: 'h-11 w-full items-center justify-center',\n  },\n  vertical: {\n    sm: 'w-7 flex-col items-center justify-start self-stretch',\n    default: 'w-9 flex-col items-center justify-start self-stretch',\n    lg: 'w-11 flex-col items-center justify-start self-stretch',\n  },\n} as const\n\n// React's injected <style> keyframes, replayed with the Web Animations API\n// (the shadow sheet takes no custom CSS). Same timings and easings.\nconst POP: Keyframe[] = [{ transform: 'scale(0.92)' }, { transform: 'scale(1.06)', offset: 0.55 }, { transform: 'scale(1)' }]\nconst ICON_IN: Keyframe[] = [\n  { opacity: 0, transform: 'scale(0.6)' },\n  { opacity: 1, transform: 'scale(1)' },\n]\n\n/**\n * <uip-stepper> — the registry Stepper as ONE web component.\n *\n * The header strip (tablist > ol > li[role=tab] with indicator + title\n * buttons and connector segments) is rendered from the `steps` property in a\n * single shadow root, with React's class strings. The default slot is the\n * content area (React's `children`), wrapped in `mt-6 flex-1` only when\n * something is slotted; use <uip-stepper-content step=\"n\"> children to show\n * one panel per step.\n *\n * Custom composition (React's `stepsSlot` + standalone parts):\n *  - `slot=\"header\"` replaces the auto-rendered <ol> (React's `stepsSlot`).\n *    `<uip-stepper-header>` is React's StepperHeader box for that strip.\n *  - Per-step rich content: `slot=\"title-n\"` / `slot=\"description-n\"` /\n *    `slot=\"icon-n\"` (1-based step index, matching `value`) replace the\n *    step's title, description and indicator glyph respectively; without\n *    them the `steps` config renders as before. `<uip-stepper-title>` /\n *    `<uip-stepper-description>` are React's StepperTitle/Description spans\n *    for custom headers. StepperItem/Indicator/Step stay data-driven — the\n *    auto header + these slots cover their custom-content cases.\n *\n * Properties: `steps` (StepperStepConfig[], also JSON via the attribute),\n * `value` (1-based active step, default 1), `orientation`, `size`.\n * Events: `value-change` (detail: { value }) when a completed step is\n * clicked — the element also updates its own `value` (uncontrolled), set it\n * back to veto.\n *\n * Motion: React injects a <style> with keyframes; here the same keyframes run\n * through element.animate() when an indicator's status changes, and the\n * connector fill uses scale utilities keyed on data-completed.\n */\nexport class UipStepper extends LitElement {\n  // :host display can't be a utility class (the host has no template of its own).\n  static styles = [tailwind, css`:host { display: block; }`]\n\n  static properties = {\n    steps: { type: Array },\n    value: { type: Number, reflect: true },\n    orientation: { reflect: true },\n    size: { reflect: true },\n    hasContent: { state: true },\n  }\n\n  steps: StepperStepConfig[] = []\n  value = 1\n  orientation: StepperOrientation = 'horizontal'\n  size: StepperSize = 'default'\n  private hasContent = false\n  private lastStatus: StepperStatus[] = []\n\n  constructor() {\n    super()\n    new ThemeController(this)\n  }\n\n  connectedCallback() {\n    super.connectedCallback()\n    this.setAttribute('data-uipkge', '')\n    this.setAttribute('data-slot', 'stepper')\n  }\n\n  private getStatus(index: number): StepperStatus {\n    const step = this.steps[index]\n    if (step?.error) return 'error'\n    if (index + 1 === this.value) return 'active'\n    if (index + 1 < this.value) return 'completed'\n    return 'pending'\n  }\n\n  private isClickable(index: number) {\n    return index + 1 < this.value\n  }\n\n  private goToStep(stepIndex: number) {\n    if (stepIndex < 1 || stepIndex > this.steps.length) return\n    if (this.steps[stepIndex - 1]?.disabled) return\n    this.value = stepIndex\n    this.dispatchEvent(new CustomEvent('value-change', { detail: { value: stepIndex }, bubbles: true, composed: true }))\n  }\n\n  private onSlotChange(e: Event) {\n    const slot = e.target as HTMLSlotElement\n    this.hasContent = slot.assignedNodes().some((n) => n.nodeType === 1 || n.textContent?.trim())\n  }\n\n  protected updated(changed: Map<string, unknown>) {\n    if (changed.has('value')) {\n      this.querySelectorAll('uip-stepper-content').forEach((c) => c.requestUpdate())\n    }\n    if (isServer) return\n    const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches\n    const indicators = this.renderRoot.querySelectorAll<HTMLElement>('[data-slot=stepper-indicator]')\n    indicators.forEach((el, i) => {\n      const svg = el.querySelector(':scope > svg')\n      svg?.setAttribute('data-slot', 'stepper-indicator-icon')\n      const status = el.dataset.status as StepperStatus\n      if (status === this.lastStatus[i]) return\n      this.lastStatus[i] = status\n      if (reduce || status === 'pending') return\n      el.animate(POP, { duration: 280, easing: 'cubic-bezier(0.22, 1.4, 0.36, 1)', fill: 'both' })\n      svg?.animate(ICON_IN, { duration: 220, easing: 'cubic-bezier(0.22, 1.2, 0.36, 1)', fill: 'both' })\n    })\n    this.lastStatus.length = indicators.length\n  }\n\n  private renderConnector(edge: 'left' | 'right' | 'top' | 'bottom', completed: boolean) {\n    const horizontal = this.orientation === 'horizontal'\n    const position = {\n      left: 'top-1/2 right-1/2 left-0 h-px -translate-y-1/2',\n      right: 'top-1/2 right-0 left-1/2 h-px -translate-y-1/2',\n      top: 'top-0 bottom-1/2 left-1/2 w-px -translate-x-1/2',\n      bottom: 'top-1/2 bottom-0 left-1/2 w-px -translate-x-1/2',\n    }[edge]\n    return html`<span\n      aria-hidden=\"true\"\n      data-slot=\"stepper-connector\"\n      data-orientation=${this.orientation}\n      data-edge=${edge}\n      class=${cn('bg-border pointer-events-none absolute overflow-hidden', position)}\n    >\n      <span\n        data-slot=\"stepper-connector-fill\"\n        data-completed=${completed ? 'true' : 'false'}\n        data-orientation=${this.orientation}\n        class=${cn(\n          'bg-primary absolute inset-0',\n          // React's injected CSS (scaleX/scaleY 0 -> 1, 320ms) as utilities.\n          'transition-[scale] duration-[320ms] ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none',\n          horizontal\n            ? 'origin-left scale-x-0 data-[completed=true]:scale-x-100'\n            : 'origin-top scale-y-0 data-[completed=true]:scale-y-100',\n        )}\n      ></span>\n    </span>`\n  }\n\n  private renderItem(step: StepperStepConfig, index: number) {\n    const status = this.getStatus(index)\n    const horizontal = this.orientation === 'horizontal'\n    const isFirst = index === 0\n    const isLast = index === this.steps.length - 1\n    const clickable = this.isClickable(index) && !step.disabled\n    const navigate = () => clickable && this.goToStep(index + 1)\n\n    const fallbackIcon: [IconNode, string] | null = step.icon\n      ? [step.icon, step.iconName ?? 'icon']\n      : status === 'completed'\n        ? [Check, 'check']\n        : status === 'error'\n          ? [X, 'x']\n          : null\n\n    return html`<li\n      data-slot=\"stepper-item\"\n      class=${cn(\n        'group/stepper-item relative min-w-0',\n        horizontal ? 'flex flex-1 flex-col items-center gap-2' : 'flex flex-row items-start gap-3 pb-6 last:pb-0',\n        step.disabled && 'opacity-50',\n      )}\n      role=\"tab\"\n      aria-selected=${status === 'active' ? 'true' : 'false'}\n      aria-disabled=${step.disabled ? 'true' : nothing}\n      data-status=${status}\n    >\n      <div class=${cn('relative flex shrink-0', indicatorAxisClasses[this.orientation][this.size])}>\n        ${isFirst ? nothing : this.renderConnector(horizontal ? 'left' : 'top', index < this.value)}\n        ${isLast ? nothing : this.renderConnector(horizontal ? 'right' : 'bottom', index < this.value - 1)}\n        <button\n          type=\"button\"\n          data-slot=\"stepper-indicator\"\n          data-status=${status}\n          class=${cn(\n            stepperIndicatorVariants({ status, size: this.size }),\n            'ring-background relative z-10 ring-4 transition-[color,background-color,box-shadow,transform] duration-200 outline-none',\n            clickable && 'focus-visible:ring-ring cursor-pointer focus-visible:ring-2 focus-visible:outline-none',\n            !clickable && 'cursor-default',\n            'focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none',\n          )}\n          ?disabled=${!clickable}\n          aria-current=${status === 'active' ? 'step' : nothing}\n          @click=${navigate}\n        >\n          <slot name=${`icon-${index + 1}`}>\n            ${fallbackIcon\n              ? icon(fallbackIcon[0], fallbackIcon[1], 'size-4')\n              : html`<span class=\"font-medium\" data-slot=\"stepper-indicator-label\">${index + 1}</span>`}\n          </slot>\n        </button>\n      </div>\n      <div\n        data-slot=\"stepper-item-content\"\n        class=${cn('min-w-0', horizontal ? 'max-w-[12rem] text-center' : 'flex-1 pt-1.5')}\n      >\n        <button\n          type=\"button\"\n          class=${cn(\n            'text-foreground text-sm font-medium text-balance transition-colors duration-200 outline-none',\n            clickable &&\n              'hover:text-primary focus-visible:text-primary focus-visible:ring-ring cursor-pointer focus-visible:ring-2 focus-visible:outline-none',\n            !clickable && 'cursor-default',\n            status === 'pending' && 'text-muted-foreground',\n            status === 'error' && 'text-destructive',\n          )}\n          ?disabled=${!clickable}\n          @click=${navigate}\n        >\n          <slot name=${`title-${index + 1}`}>${step.title}</slot>\n        </button>\n        <slot name=${`description-${index + 1}`}>\n          ${step.description\n            ? html`<p class=\"text-muted-foreground mt-0.5 text-xs text-balance\">${step.description}</p>`\n            : nothing}\n        </slot>\n      </div>\n    </li>`\n  }\n\n  render() {\n    const horizontal = this.orientation === 'horizontal'\n    return html`<div\n      part=\"base\"\n      class=\"w-full\"\n      role=\"tablist\"\n      aria-orientation=${this.orientation}\n      data-orientation=${this.orientation}\n    >\n      <slot name=\"header\">\n        ${this.steps.length > 0\n          ? html`<ol class=${cn('flex', horizontal ? 'flex-row items-start' : 'flex-col items-stretch')}>\n              ${this.steps.map((s, i) => this.renderItem(s, i))}\n            </ol>`\n          : nothing}\n      </slot>\n      <div class=${this.hasContent ? 'mt-6 flex-1' : ''}>\n        <slot @slotchange=${this.onSlotChange}></slot>\n      </div>\n    </div>`\n  }\n}\n\n/**\n * <uip-stepper-content step=\"n\"> — React's StepperContent: a tabpanel shown\n * only while `step` equals the active step. The active step comes from\n * `active-step` when set, else from the closest <uip-stepper>'s `value`.\n */\nexport class UipStepperContent extends LitElement {\n  // No :host display rule: it would override the UA `[hidden]` rule used to hide inactive panels.\n  static styles = [tailwind]\n\n  static properties = {\n    step: { type: Number },\n    activeStep: { type: Number, attribute: 'active-step' },\n  }\n\n  step = 1\n  activeStep?: number\n\n  constructor() {\n    super()\n    new ThemeController(this)\n  }\n\n  connectedCallback() {\n    super.connectedCallback()\n    this.setAttribute('data-uipkge', '')\n    this.setAttribute('data-slot', 'stepper-content')\n  }\n\n  private get isActive() {\n    const active = this.activeStep ?? (isServer ? 1 : (this.closest('uip-stepper')?.value ?? 1))\n    return this.step === active\n  }\n\n  protected willUpdate() {\n    // Hide the host itself so an inactive panel takes no space in the layout.\n    this.toggleAttribute('hidden', !this.isActive)\n  }\n\n  render() {\n    const active = this.isActive\n    return html`<div\n      part=\"base\"\n      data-slot=\"stepper-content\"\n      class=\"stepper-content motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-bottom-1 motion-safe:duration-200 motion-safe:ease-out\"\n      role=\"tabpanel\"\n      aria-hidden=${active ? 'false' : 'true'}\n    >\n      <slot></slot>\n    </div>`\n  }\n}\n\n/**\n * <uip-stepper-header> — React's StepperHeader: the custom header-strip box\n * for `slot=\"header\"`. Class string verbatim; host `class` = React's\n * `className`.\n */\nexport class UipStepperHeader extends LitElement {\n  static styles = [tailwind, css`:host { display: block; }`]\n\n  constructor() {\n    super()\n    new ThemeController(this)\n  }\n\n  connectedCallback() {\n    super.connectedCallback()\n    this.setAttribute('data-uipkge', '')\n    this.setAttribute('data-slot', 'stepper-header')\n  }\n\n  render() {\n    return html`<div part=\"base\" class=\"stepper-header flex items-center gap-0\"><slot></slot></div>`\n  }\n}\n\n/**\n * <uip-stepper-title> — React's StepperTitle span for custom headers.\n * Class string verbatim; host `class` = React's `className`.\n */\nexport class UipStepperTitle extends LitElement {\n  static styles = [tailwind]\n\n  constructor() {\n    super()\n    new ThemeController(this)\n  }\n\n  connectedCallback() {\n    super.connectedCallback()\n    this.setAttribute('data-uipkge', '')\n    this.setAttribute('data-slot', 'stepper-title')\n  }\n\n  render() {\n    return html`<span part=\"base\" class=\"text-foreground text-sm font-medium\"><slot></slot></span>`\n  }\n}\n\n/**\n * <uip-stepper-description> — React's StepperDescription span for custom\n * headers. Class string verbatim; host `class` = React's `className`.\n */\nexport class UipStepperDescription extends LitElement {\n  static styles = [tailwind]\n\n  constructor() {\n    super()\n    new ThemeController(this)\n  }\n\n  connectedCallback() {\n    super.connectedCallback()\n    this.setAttribute('data-uipkge', '')\n    this.setAttribute('data-slot', 'stepper-description')\n  }\n\n  render() {\n    return html`<span part=\"base\" class=\"text-muted-foreground text-xs\"><slot></slot></span>`\n  }\n}\n\ncustomElements.get('uip-stepper') || customElements.define('uip-stepper', UipStepper)\ncustomElements.get('uip-stepper-content') || customElements.define('uip-stepper-content', UipStepperContent)\ncustomElements.get('uip-stepper-header') || customElements.define('uip-stepper-header', UipStepperHeader)\ncustomElements.get('uip-stepper-title') || customElements.define('uip-stepper-title', UipStepperTitle)\ncustomElements.get('uip-stepper-description') ||\n  customElements.define('uip-stepper-description', UipStepperDescription)\n\ndeclare global {\n  interface HTMLElementTagNameMap {\n    'uip-stepper': UipStepper\n    'uip-stepper-content': UipStepperContent\n    'uip-stepper-header': UipStepperHeader\n    'uip-stepper-title': UipStepperTitle\n    'uip-stepper-description': UipStepperDescription\n  }\n}\n",
      "type": "registry:file",
      "target": "src/uip/components/stepper/stepper.ts"
    },
    {
      "path": "packages/elements-lit/src/components/stepper/stepper.variants.ts",
      "content": "import type { VariantProps } from 'class-variance-authority'\nimport { cva } from 'class-variance-authority'\n\n/**\n * Variant definitions live in their own file (rather than the package\n * `index.ts`) so `StepperIndicator.vue` / `StepperStep.vue` can import\n * them without creating a circular dependency back through the index.\n * Sibling pattern to `card/card.variants.ts`.\n */\nexport const stepperIndicatorVariants = cva('flex items-center justify-center rounded-full font-semibold shrink-0', {\n  variants: {\n    status: {\n      pending: 'bg-muted text-muted-foreground',\n      active: 'bg-primary text-primary-foreground shadow-sm',\n      completed: 'bg-primary text-primary-foreground',\n      error: 'bg-destructive text-destructive-foreground',\n    },\n    size: {\n      sm: 'size-7 text-xs [&>svg]:size-3.5',\n      default: 'size-9 text-sm [&>svg]:size-4',\n      lg: 'size-11 text-base [&>svg]:size-5',\n    },\n  },\n  defaultVariants: {\n    status: 'pending',\n    size: 'default',\n  },\n})\n\nexport type StepperIndicatorVariants = VariantProps<typeof stepperIndicatorVariants>\n",
      "type": "registry:file",
      "target": "src/uip/components/stepper/stepper.variants.ts"
    }
  ],
  "dependencies": [
    "class-variance-authority",
    "lit",
    "lucide"
  ],
  "registryDependencies": [
    "https://uipkge.dev/r/lit/lit-core.json"
  ]
}
