Skip to content
Component installs need the registry setup— the Base UI shadcn project, the @vegastack namespace and the Cloudflare Access service token.
VegaStack Design

Migrating to the shadcn reset

An agent-executable upgrade from 0.4.x — every retired component, deleted token and changed prop, with the searches that prove a project is clean.

Last updated

This release rebuilds VegaStack Design on shadcn base-nova, used as-is. Every component shadcn ships is now upstream's own file plus a small recorded patch, so its props, its variants and its behaviour are upstream's. What VegaStack adds on top is a recorded set of exceptions — the focus outline with no glow, the hand cursor, an accessibility set, four status families, the chart, tag and brand palettes, and our own motion utilities — and nothing else.

There is no compatibility layer. No aliases, no deprecation shims, no re-exported old names, no automated codemod. A retired import fails to resolve, which is the easy half. A deleted CSS token compiles to nothing and the page keeps rendering — just wrong — which is the half you have to hunt. Section 12 is the hunt.

The version numbers are @vegastack/design 0.5.0, @vegastack/design-tokens 0.5.0, @vegastack/ui 0.10.0. It is a minor bump with a breaking surface, not a 1.0.

This page is written to be executed by an agent (Claude Code, Codex) against a real consumer project. Work it top to bottom. Every rule is stated as an exact before and after, and every category has an rg pattern that proves the project is clean of it.

1. How an agent should use this page

  1. Read sections 2 and 3 to know what you are doing and in what order.
  2. Run the upgrade stage by stage (section 3). Verify after each stage; do not batch.
  3. For each compile error, find the component in sections 7 to 10 and apply the stated rewrite.
  4. Run every search in section 12. A clean project returns nothing for all of them.
  5. If a page looks wrong but compiles, read section 13 — a deleted token is silent.

Two rules that prevent the common failure modes:

  • Never reintroduce a deleted token by defining it yourself. Writing --surface-2: … back into globals.css makes the old class strings compile again and freezes the project on a vocabulary the system no longer has. Rewrite the class instead.
  • Never edit a file under components/ui/. Those are re-pulled in stage 4 and --overwrite destroys local edits. Fix call sites, or wrap.

2. What changes visually

These land the moment the token layer updates, before you touch a line of your own code. They are the point of the release, not side effects.

Before (0.4.x)After (0.5.0)
A soft 3px ring-ring/50 glow around a focused controlOne crisp 2px :focus-visible outline in the near-black / near-white ink. Text entry tints its border instead
An arrow cursor on buttons; an explicit cursor-default on menu rowsA hand cursor on every control, menu rows included
A warm neutral ramp (OKLCH hue 75)shadcn's own neutral — pure achromatic. Light background is white
A three-rung surface ladder (surface-1/2/3) driving every hover and pressmuted for a well or track, accent for a hover; each component writes its own hover and press
Every control had a pressed step, enforced by lintA hover with no pressed step is upstream's own behaviour and is fine
Radius capped at rounded-lg; rounded-xl bannedRadius derives from one --radius (0.625rem); a card and a dialog wear rounded-xl
One sanctioned shadow (--shadow-overlay), everything else flatshadow-sm / shadow-md / shadow-lg, per component, as shadcn uses them
A 14px product type scale bound onto every text-* utilityTailwind's stock scale. text-sm is 14px, text-base is 16px — see section 5
Weight ladder 400/500; font-semibold and font-bold were lint errorsOrdinary utilities. tracking-*, blur-*, shadow-* and text-4xl are ordinary too
Colour transitions banned (transition-colors was a lint error)transition-all duration-100 ease-in-out is upstream's vocabulary and is legal
Three named z-bands (--z-raised/overlay/toast)z-50 and DOM order
The modal scrim was --overlay at 28% alphaUpstream's bg-black/10 with a backdrop-blur-xs — noticeably lighter
Sheet ran on Base UI Drawer (swipe, snap points)Sheet is Base UI Dialog with side on the content; the gesture work moved to the new Drawer
Tabs' default list was the underlineTabs' default list is upstream's grey pill track; line is the alternative
Table body cells wrappedEvery cell is whitespace-nowrap; a wide table scrolls in a plain container

Three of those can read as regressions and are not:

  • The table scroll container is not a keyboard tab stop. Upstream's Table wraps itself in a plain overflow-x-auto div. If a table of yours is genuinely wide, give the wrapper a tabIndex={0} and an aria-label in your own code. This is a known, recorded gap.
  • A toast fired from inside an open dialog renders behind the scrim. There is one z-50 band and DOM order decides; the <Toaster /> mounts first. Raise the toast after the dialog closes, or mount your toaster after the dialog.
  • Alert is always role="alert". Upstream sets it unconditionally, so a statically rendered banner is now an assertive live region. The old live prop, which gated that, is gone. If you render a page of standing banners, use your own <section> with an <h2> instead.

3. The upgrade procedure

Each stage ends with a verification you can run. Do not skip ahead — a later stage's errors are unreadable if an earlier stage is half-applied.

Stage 1 — packages

pnpm add @vegastack/design@^0.5.0 @vegastack/design-tokens@^0.5.0

If your project pins lucide-react, move it to ^1.47.0; that is the range the system is built and tested against.

Verify: pnpm ls @vegastack/design @vegastack/design-tokens reports 0.5.x for both, and node -e "require('@vegastack/design')" resolves.

Stage 2 — make it compile

pnpm tsc --noEmit

Every retired import and every removed prop is a type error. Work the errors against sections 6 to 10 until this is clean. Do not run the app yet; a half-migrated tree renders misleading output.

Verify: pnpm tsc --noEmit exits 0.

Stage 3 — find what the compiler cannot see

Run every search in section 12. A deleted CSS token is not a type error; it compiles to an empty value and the element inherits whatever was above it. Fix each hit against sections 4 and 5.

Verify: each rg command in section 12 returns no matches outside components/ui/.

Stage 4 — re-pull the copied-in components

Every component's file changed. If you edited a copied-in file, move that change into a wrapper or a token override before you overwrite it.

npx --package=@vegastack/design vegastack-design check-updates   # everything reads as an update
pnpm dlx shadcn@latest add @vegastack/<name> --diff              # read one
pnpm dlx shadcn@latest add @vegastack/<name> --overwrite         # take it

Delete the files for the twenty names in sections 8 and 9 — shadcn add will not remove them for you, and a stale components/ui/icon-button.tsx keeps compiling against tokens that no longer exist.

Verify: vegastack-design check-updates reports every remaining component up to date, and pnpm tsc --noEmit is still clean.

Stage 5 — look at it

pnpm build && pnpm start

Walk the app in both themes and at a phone width. Section 2 is the list of what should look different; section 13 is the list of what a leftover looks like.

Verify: tab through a form — every focused control shows a 2px outline, and a focused text input shows a tinted border rather than an outline. If nothing shows, base.css is not loading; see Theming.

4. Deleted tokens, with an exact replacement for each

Nothing was renamed in place. Whole families were deleted, because upstream expresses the same thing with a plain Tailwind utility. Write the utility.

4.1 Surfaces and scrim

DeletedWasWrite instead
--surface-1the rest fill / well / trackbg-muted
--surface-2the hover rungbg-accent (pair with text-accent-foreground)
--surface-3the pressed / selected rungbg-accent — there is no third rung; selection uses data-* now
--overlaythe modal scrim, 28% alphanothing — DialogOverlay paints upstream's bg-black/10
--shadow-overlaythe one sanctioned shadowshadow-md (a popover) · shadow-lg (a modal)

muted, accent and secondary resolve to the same value and are all kept, so you can retune one role without moving the others.

4.2 The alpha ladder (19 entries)

Every --alpha-* is gone. Write the literal percentage the token carried.

DeletedWrite instead
--alpha-surface-faint/5
--alpha-hover/7
--alpha-border/8
--alpha-pressed, --alpha-ink-tint/10
--alpha-ink-tint-strong/15
--alpha-border-subtle/20
--alpha-border-soft, --alpha-input/30
--alpha-wash-faint/40
--alpha-wash, --alpha-outline-border, --alpha-outline-soft/50
--alpha-wash-strong, --alpha-backdrop-soft/60
--alpha-tint-border/70
--alpha-link-hover/88
--alpha-glass/90
--alpha-glass-hover/95

So bg-foreground/(--alpha-hover) becomes bg-foreground/7, and border-ring/(--alpha-tint-border) becomes border-ring/70.

4.3 The opacity ladder (4 entries)

DeletedWasWrite instead
--opacity-track25%opacity-25
--opacity-dim50%opacity-50
--opacity-hint-soft60%opacity-60
--opacity-hint70%opacity-70

Colour compositing takes a slash (bg-foreground/10); whole-element fade takes opacity-NN. The lint rule that policed that distinction is gone, but the distinction is still the right one.

4.4 Sizes, icons, layout widths, z-index, radius

DeletedWasWrite instead
--size-xs1.5remh-6
--size-sm1.75remh-7
--size-md2remh-8
--size-lg2.5remh-10
--icon-compact0.75remsize-3
--icon-inline0.875remsize-3.5
--icon-default1remsize-4
--icon-action1.25remsize-5
--icon-feature1.5remsize-6
--panel-width-sm14remw-56
--panel-width-md18remw-72
--panel-width-lg20remw-80
--layout-header-height3.5remh-14
--layout-overlay-max-heightcalc(100dvh - 16rem)max-h-[calc(100dvh-16rem)]
--sidebar-width15remnothing — SidebarProvider now sets 16rem itself
--sidebar-width-icon3remnothing — set by SidebarProvider
--sidebar-width-mobile18remnothing — set by SidebarProvider
--z-raised10z-10
--z-overlay50z-50
--z-toast60z-50 — there is one overlay band now
--radius-xs0.125remrounded-xs
--radius-sharp2pxnothing — the sharp gesture was marketing

--radius-sm, --radius-md and --radius-lg are no longer authored tokens, but the utilities still work: upstream derives the whole ramp from one --radius (0.625rem) at 0.6 / 0.8 / 1 / 1.4 / 1.8 / 2.2 / 2.6. Their computed values moved, so rounded-sm is now 0.375rem and rounded-xl is 0.875rem. Override --radius alone to reshape everything.

4.5 Status colour steps

The four status families kept their base value, their -foreground ink and their -text ink. Every derived step is gone.

DeletedWrite instead
--destructive-subtle, --success-subtle, --warning-subtle, --info-subtlethe family at /10: bg-destructive/10
--<family>-subtle-hoverhover:bg-destructive/20
--<family>-subtle-activeactive:bg-destructive/20 (or drop the press)
--<family>-hover, --<family>-active (all four families)the fill at an alpha: hover:bg-destructive/80
--primary-hover, --primary-activehover:bg-primary/80
--destructive-borderborder-destructive
--muted-foreground-fainttext-muted-foreground

The ten --tag-* trios keep their -subtle step — --tag-blue-subtle and friends are not deleted. Only the four status families lost theirs.

4.6 Marketing tokens

--font-family-pixel, --motion-blur and --effect-blur-glass went with the marketing layer. There is no replacement; delete the usage.

4.7 What is kept and still ours

--brand / --brand-text; the ten --tag-* trios (--tag-blue, --tag-blue-subtle, --tag-blue-text, and the same for cyan, green, lime, yellow, orange, red, pink, magenta, purple); --chart-1--chart-8 and --chart-single; --media-scrim, --media-scrim-strong, --media-foreground; the --duration-* and --motion-ease-* pairs behind the motion-* utilities; and the Geist font families. The info / success / warning / destructive families are kept and rewritten in upstream's own destructive shape.

5. Typography: the whole scale shifted one step

This is the change most likely to be invisible and wrong at the same time.

Before, the token layer rebound Tailwind's --text-* scale onto a 14px product ladder. text-sm was 12px and text-base was 14px. Now Tailwind's stock scale is the scale, unmodified. Every text-* utility in your code got one step larger without you touching it.

The class you wroteRendered beforeRenders nowTo keep the old size, write
text-xs11px12pxtext-[11px]
text-sm12px14pxtext-xs
text-base14px16pxtext-sm
text-lg16px18pxtext-base
text-xl18px20pxtext-lg
text-2xl20px24pxtext-xl
text-3xl24px30pxtext-2xl

In most product UI the right move is to accept the new size rather than shift every class down — upstream's components are built for the stock scale, and a 14px body beside a 16px component reads as a bug. Shift down only where a dense surface genuinely needs it (a data grid, a dense table).

The role utilities are deleted

Deleted utilityWasWrite instead
text-h124px / 32, w400, -0.02emtext-2xl font-semibold tracking-tight
text-h220px / 28, w400, -0.015emtext-xl font-semibold tracking-tight
text-h318px / 24, w400, -0.01emtext-lg font-semibold
text-h416px / 22, w500text-base font-medium
text-label14px / 20, w500text-sm font-medium
text-strong14px / 20, w600text-sm font-semibold
text-label-sm12px / 16, w500text-xs font-medium
text-code13px / 20, monofont-mono text-sm
text-code-sm12px / 16, monofont-mono text-xs
text-mono-label12px / 16, mono, +0.05emfont-mono text-xs tracking-wider
text-display-sm32px / 36, -0.04emtext-4xl tracking-tighter (36px) or text-[2rem]/9
text-display-md40px / 44, -0.045emtext-5xl tracking-tighter (48px) or text-[2.5rem]/11
text-display-lg56px / 60, -0.05emtext-6xl tracking-tighter (60px) or text-[3.5rem]/15
text-display-xl72px / 76, -0.06emtext-7xl tracking-tighter (72px)

The text-h* utilities carried weight 400 because of the old weight ladder. Upstream headings are font-semibold; the rewrites above adopt that deliberately. If you need the old look exactly, drop the weight class.

Three bans went with the ladder and are now ordinary utilities: font-semibold / font-bold, a raw tracking-*, and text-4xl and above. Uppercase is no longer mono-exclusive.

Rendered rich text is unchanged: proseClassName from @vegastack/design is still the one recipe, and MarkdownView and TextEdit still wear it.

6. Status colour now has two inks

The one addition most likely to bite, because the wrong choice still renders.

  • --<family>-foreground is the ink on the solid fill: bg-destructive text-destructive-foreground.
  • --<family>-text is the ink on the page, and on the family's own /10/30 tint: bg-destructive/10 text-destructive-text.

Using the fill itself as ink on its own tint — bg-destructive/10 text-destructive, which is what upstream writes — measures 3.99:1 in light, under the AA floor. The -text half reads 6.97:1 on the same composite. destructive-text, info-text, success-text and warning-text all exist for exactly this, and brand-text is the same role for the brand accent.

// Wrong — compiles, renders, fails AA on its own tint
<div className="rounded-lg bg-destructive/10 p-3 text-destructive">…</div>

// Right
<div className="rounded-lg bg-destructive/10 p-3 text-destructive-text">…</div>

info stays rationed to links and informational UI. A status hue means status, not sentiment: a favourite star is text-foreground, not text-warning.

7. Removed exports

7.1 From @vegastack/design

Seven shared class-string recipes are deleted with no alias. Each described a system — a three-rung surface ladder, a per-tone fill map, a shared text-entry chrome, a raised-chip recipe — that no longer exists.

RemovedWhat it wasReplace with
surfaceInteractivehover:bg-surface-2 active:bg-surface-3hover:bg-accent hover:text-accent-foreground
surfaceInteractiveGroupthe same, keyed off a group/washgroup-hover/wash:bg-accent
fillInteractivea per-tone hover / press mapthe fill at an alpha: hover:bg-primary/80
FillToneits tone unionnothing — the tone axis is gone
fieldControlthe shared text-entry chromecompose upstream's Input / Textarea / InputGroup instead of re-deriving it
fieldControlGroupits wrapper twinInputGroup
selectedChipVariantsthe raised-chip-on-a-track recipeupstream's TabsTrigger / ToggleGroupItem chrome

Unchanged and still exported: cn, TIMINGS, FLOATING, mergeRefs, prose / proseClassName / ProseElement, the icon runtime (Icon, BrandIcon, createAnimatedIcon), the Tailwind preset and the vegastack-design CLI. cn is plain twMerge again, because the custom font-size class group it extended no longer exists.

7.2 Variant recipes that components no longer export

Upstream exports a cva recipe only where a consumer genuinely composes with it. Thirty-four are gone. If you imported one to style a link or a custom element, inline the classes you need or wrap the component instead.

alertVariants · attachmentVariants · attachmentMediaVariants · avatarVariants · avatarGroupVariants · bubbleVariants · bubbleReactionsVariants · checkboxVariants · chipInputVariants · comboboxInputVariants · comboboxInputGroupVariants · comboboxTriggerVariants · dialogContentVariants · emptyVariants · emptyMediaVariants · fieldVariants · floatingPopupVariants · itemVariants · itemMediaVariants · kbdVariants · menuItemVariants · paginationLinkVariants · progressVariants · progressIndicatorVariants · radioGroupVariants · segmentedVariants · segmentedItemVariants · selectTriggerVariants · sheetVariants · sidebarMenuButtonVariants · skeletonVariants · spinnerVariants · switchVariants · switchThumbVariants

Still exported: buttonVariants, badgeVariants, toggleVariants, tabsListVariants, imageVariants, markerVariants, statValueVariants, statusIconVariants, plus the new buttonGroupVariants, navigationMenuTriggerStyle and ChartStyle.

7.3 The <Name>Props type aliases

Upstream does not export a props interface per part, so the ButtonProps, DialogContentProps, FieldRootProps, TableCellProps style aliases are gone across every reset component. Use React.ComponentProps<typeof Button> instead:

// Before
import { type ButtonProps } from "@/components/ui/button";
type Props = ButtonProps & { count: number };

// After
import * as React from "react";
import { Button } from "@/components/ui/button";
type Props = React.ComponentProps<typeof Button> & { count: number };

8. The ten retired components

Each was deleted in favour of something upstream already does. Each row states what did not survive, because in several cases something real did.

8.1 IconButtonButton at an icon size

IconButtonButton
size="xs"size="icon-xs" (24px)
size="sm"size="icon-sm" (28px)
size="md"size="icon" (32px, the default)
size="lg"size="icon-lg" (36px)
shape="round"className="rounded-full"
shape="square"the default — drop it
iconButtonGeometry(size, shape)buttonVariants({ size: "icon-sm" }), plus rounded-full
tone="…"see section 10.1 — the tone axis is gone
everything else (variant, loading, disabled, render)unchanged
// Before
<IconButton aria-label="Add item" variant="outline" size="sm">
  <PlusIcon />
</IconButton>;

// After
import { Button } from "@/components/ui/button";
import { PlusIcon } from "lucide-react";

<Button variant="outline" size="icon-sm" aria-label="Add item">
  <PlusIcon />
</Button>;

What did not survive: the compile-time accessible-name guarantee. IconButton made a missing aria-label a type error; <Button size="icon"> without one is a valid TypeScript program. The invariant is still enforced inside the design-system repo, but in your code it is on you. The rendered element's data-slot changes from icon-button to button, and data-shape is gone. Button also no longer emits data-variant, data-size or data-tone — if you styled or tested against those attributes, key off a class or a data-* of your own.

8.2 OTPInputInputOTP

Ours was Base UI's OTPField with one focusable <input> per slot. Upstream's is the input-otp package: one hidden input behind presentational slots, which is what makes paste, autofill and the platform SMS suggestion work.

OTPInputInputOTP composition
length={6}maxLength={6}
groups={[3, 3]}two InputOTPGroups with an InputOTPSeparator between
separator={<Dot />}the children of InputOTPSeparator (default is a minus glyph)
separatorClassNameclassName on InputOTPSeparator
slotClassNameclassName on each InputOTPSlot
size="sm" | "md" | "lg"gone — a slot is size-8; override with className on the slot
value / defaultValuesame
onValueChange(v, details)onChange(v) — there is no Base UI event-details object
onValueComplete(v)onComplete(v)
aria-invalidput it on InputOTP; the slot reads not-data-[active=true]:aria-invalid:border-destructive
// Before
<OTPInput
  length={6}
  groups={[3, 3]}
  onValueComplete={verify}
  aria-label="Verification code"
/>;

// After
import {
  InputOTP,
  InputOTPGroup,
  InputOTPSeparator,
  InputOTPSlot,
} from "@/components/ui/input-otp";

<InputOTP maxLength={6} onComplete={verify} aria-label="Verification code">
  <InputOTPGroup>
    <InputOTPSlot index={0} />
    <InputOTPSlot index={1} />
    <InputOTPSlot index={2} />
  </InputOTPGroup>
  <InputOTPSeparator />
  <InputOTPGroup>
    <InputOTPSlot index={3} />
    <InputOTPSlot index={4} />
    <InputOTPSlot index={5} />
  </InputOTPGroup>
</InputOTP>;

What did not survive: the three slot size tiers, and the per-slot "Character N of M" labels — upstream has one input, so the field has one accessible name and nothing to enumerate.

8.3 PasswordInput → an InputGroup composition

PasswordInputInputGroup composition
value / onChangeon InputGroupInput
revealLabel / hideLabelaria-label on the toggle Button, swapped on visible
defaultVisibleyour own useState
requirements={[…]}your own list under the field (each row is text + an icon)
strengthgone — compose Progress if you want a meter
"use client";
import * as React from "react";
import { EyeIcon, EyeOffIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
  InputGroup,
  InputGroupAddon,
  InputGroupInput,
} from "@/components/ui/input-group";

export function PasswordField() {
  const [visible, setVisible] = React.useState(false);
  return (
    <InputGroup>
      <InputGroupInput
        type={visible ? "text" : "password"}
        aria-label="Password"
        autoComplete="current-password"
      />
      <InputGroupAddon align="inline-end">
        <Button
          type="button"
          variant="ghost"
          size="icon-xs"
          aria-label={visible ? "Hide password" : "Show password"}
          aria-pressed={visible}
          onClick={() => setVisible((v) => !v)}
        >
          {visible ? <EyeOffIcon /> : <EyeIcon />}
        </Button>
      </InputGroupAddon>
    </InputGroup>
  );
}

What did not survive: the built-in requirements checklist and its live announcements.

8.4 CheckboxGroupFieldSet + Checkbox

CheckboxGroupUpstream composition
value / defaultValueyour own useState<string[]>
onValueChange(next)per-Checkbox onCheckedChange, folded into that state
allValues={[…]} + parent checkboxone Checkbox whose checked is "indeterminate" when the set is mixed
<Checkbox value="x" /> childrenField rows with orientation="horizontal", each a Checkbox and a FieldLabel
aria-label on the groupFieldLegend inside FieldSet
"use client";
import * as React from "react";
import { Checkbox } from "@/components/ui/checkbox";
import {
  Field,
  FieldGroup,
  FieldLabel,
  FieldLegend,
  FieldSet,
} from "@/components/ui/field";

const ALL = ["email", "sms", "push"] as const;

export function NotificationChannels() {
  const [picked, setPicked] = React.useState<string[]>(["email"]);
  const toggle = (value: string, on: boolean) =>
    setPicked((prev) =>
      on ? [...prev, value] : prev.filter((v) => v !== value),
    );

  return (
    <FieldSet>
      <FieldLegend>Notifications</FieldLegend>
      <FieldGroup data-slot="checkbox-group">
        {ALL.map((value) => (
          <Field key={value} orientation="horizontal">
            <Checkbox
              id={value}
              checked={picked.includes(value)}
              onCheckedChange={(on) => toggle(value, on === true)}
            />
            <FieldLabel htmlFor={value}>{value}</FieldLabel>
          </Field>
        ))}
      </FieldGroup>
    </FieldSet>
  );
}

data-slot="checkbox-group" survives as upstream's own slot name on FieldGroup — it is what tightens the group's gap, not a reference to the retired component.

What did not survive: the select-all arithmetic. It is one expression of your own state: picked.length === ALL.length ? true : picked.length ? "indeterminate" : false.

8.5 FieldInlineEditableCell

FieldInline was a click-to-edit text field, not a layout prop, so it maps onto EditableCell — which does the same job and more — rather than onto upstream's Field orientation.

FieldInlineEditableCell
valuevalue
onCommit(next)onCommit(next) — may now return a promise, which engages the saving / saved / error indicator
placeholdereditor={{ type: "text", placeholder }}
labellabel
aria-label / aria-labelledbylabel (the cell resolves label, then placeholder, then a generic fallback)
editing / onEditingChangeediting / onEditingChange
disabled / readOnlysame
tabIndex={-1} for a grid hostfocusMode="managed"
refref — the cell's root <span>, not the swapping display / edit host
data-slot="field-inline"data-slot="editable-cell-display" idle, "editable-cell-input" editing
errorgone — wrap the cell in Field and render FieldError
borderlessgone — pass the flattening classes yourself

For a bespoke editor, useInlineEdit plus upstream Input is exactly what the cell runs internally.

What did not survive: error (the invalid tint plus the role="status" message) and borderless (the seamless in-place title variant).

8.6 Segmented → a joined ToggleGroup

SegmentedToggleGroup
value: stringvalue: string[] — pass [value]
onValueChange(next: string)onValueChange(next: string[]) — read next[0]
always exactly one selectednot enforced — ignore an empty next to keep one active
size="sm" | "md" | "lg"size on the group (sm / default / lg)
joined track, alwaysspacing={0}
<SegmentedItem value><ToggleGroupItem value>
vertical: not supportedorientation="vertical"
// Before
<Segmented value={view} onValueChange={setView} size="sm" aria-label="View">
  <SegmentedItem value="list">List</SegmentedItem>
  <SegmentedItem value="board">Board</SegmentedItem>
</Segmented>

// After
<ToggleGroup
  value={[view]}
  onValueChange={(next) => {
    const [selected] = next;
    if (selected) setView(selected);
  }}
  variant="outline"
  size="sm"
  spacing={0}
  aria-label="View"
>
  <ToggleGroupItem value="list">List</ToggleGroupItem>
  <ToggleGroupItem value="board">Board</ToggleGroupItem>
</ToggleGroup>;

What did not survive: the "always one selected" invariant, which is now the three-line guard above, and the raised-chip-on-a-track look.

8.7 SplitButton → a ButtonGroup composition

SplitButtonButtonGroup composition
children (the default label)the first Button
onClickthat Button's onClick
actions={[{ label, onSelect }]}DropdownMenuItem children
menuLabelaria-label on the trigger Button
variant / disabled / loadingon each Button (set them on both halves to match)
import { Button } from "@/components/ui/button";
import { ButtonGroup } from "@/components/ui/button-group";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ChevronDownIcon } from "lucide-react";

<ButtonGroup>
  <Button onClick={save}>Save</Button>
  <DropdownMenu>
    <DropdownMenuTrigger
      render={<Button size="icon" aria-label="More save options" />}
    >
      <ChevronDownIcon />
    </DropdownMenuTrigger>
    <DropdownMenuContent align="end">
      <DropdownMenuItem onClick={saveAndClose}>Save and close</DropdownMenuItem>
      <DropdownMenuItem onClick={saveAsDraft}>Save as draft</DropdownMenuItem>
    </DropdownMenuContent>
  </DropdownMenu>
</ButtonGroup>;

What did not survive: nothing behavioural. The seam, the shared radius and the focus z-raise are ButtonGroup's. You set variant and disabled twice instead of once.

8.8 ProgressIndicatorProgress + Spinner

ProgressIndicatorReplacement
value / max<Progress value={…} max={…} /> — same props
aria-labelaria-label on Progress
variant="inline-value"ProgressLabel + ProgressValue inside Progress
indeterminate<Spinner />
shape="circle" | "squircle"gone — there is no radial form
segments={n} / segmentsFillgone — a single determinate bar
size="xs" … "lg"className on ProgressTrack (h-1 is the default)
trackClassNameclassName on ProgressTrack
indicatorClassNameclassName on ProgressIndicator
import {
  Progress,
  ProgressIndicator,
  ProgressLabel,
  ProgressTrack,
  ProgressValue,
} from "@/components/ui/progress";

<Progress value={step} max={total} aria-label="Onboarding progress">
  <ProgressLabel>Setup</ProgressLabel>
  <ProgressValue />
  <ProgressTrack>
    <ProgressIndicator />
  </ProgressTrack>
</Progress>;

Progress reports aria-valuenow in the same units as max (steps, not a percentage), which is better ARIA than the percentage-only form it replaces. Note the name collision: ProgressIndicator is now a part of Progress, not the retired component — an unresolved import of the old name is a hint that you have both in the tree.

What did not survive: the radial ring, the squircle and the segmented dash bar — three visual shapes, no behaviour.

8.9 OnboardingChecklist → the onboarding-01 block

There is no drop-in replacement, and that is the disposition: a getting-started checklist is a screen you copy once and then edit. pnpm dlx shadcn@latest add @vegastack/onboarding-01 and own it.

OnboardingChecklistIn the block
title, done, totalliterals in the copied page; done is derived from the step array
collapsed / defaultCollapsedReact.useState in the copied component, if you want it
onCollapsedChangethe same state setter
collapseLabel / expandLabelthe strings in the copied markup
the determinate barProgress, composed directly
OnboardingChecklistItem (icon, done)a <button> row in the copied component

Two accessibility properties to keep when you edit the copy, both of which the component asserted: the collapsed pill carries visible text (title plus "n/N"), so it must not take an aria-label — that would replace the accessible name and break SC 2.5.3 Label in Name; append the action as sr-only text instead. The expanded card's collapse toggle is icon-only, so there aria-label is the whole name.

8.10 FloatingSurface — internal only

The shared portal, positioner and surface composer behind every anchored overlay. Every upstream popup now owns its own chrome, so there is nothing to replace. If you installed it directly, delete it; the component you were composing it into already paints its own surface.

What did not survive: nothing. Its one exception, the panel search row (a sticky header row with no nested bordered input), ships as the internal panel-search item and is used in both places it was used before.

9. Removed outright: the marketing layer

Ten components were deleted with no replacement, along with the MarketingSurface theme scope, its .vs-marketing selectors, the marketing tokens (--radius-sharp, --font-family-pixel) and every marketing lint rule:

comparison-matrix · figure-frame · logo-row · marketing-surface · particle-field · pricing-section · ruled-band · section-header · testimonial · staggered-text-reveal

If you were using one, its markup is yours now — copy the 0.4.x source out of your components/ui/ directory before you re-pull, because that is the only copy that will exist.

announcement-banner, terminal and code-block sat in the same family and were kept. They moved in the docs nav (to Feedback and to Content); their APIs are unchanged.

10. API changes, component by component

Every component shadcn ships now has upstream's API. This lists what actually moved in code you are likely to have written. When in doubt, the component's own page under Components is the live contract.

10.1 Button — variant × tone became upstream's flat variant

The two-axis API is gone. There is no tone prop, no --btn-* custom properties and no cta variant.

BeforeAfter
variant="solid" tone="neutral"variant="default"
variant="soft" tone="neutral"variant="secondary"
variant="outline" tone="neutral"variant="outline"
variant="ghost" tone="neutral"variant="ghost"
variant="link" tone="neutral"variant="link"
variant="soft" tone="destructive"variant="destructive" (still a soft tint, not a solid red)
variant="solid" tone="destructive"variant="destructive" — there is no solid-red tier
tone="success" | "warning" | "info"gone — no Button variant carries them; use the component that means it
variant="cta"gone with the marketing layer
size="xs" | "sm" | "md" | "lg"size="xs" | "sm" | "default" | "lg" (h-6 / h-7 / h-8 / h-9)
no icon tier — that was IconButtonsize="icon" | "icon-xs" | "icon-sm" | "icon-lg"
data-tone, data-shapegone, and so are data-variant and data-size

loading survives unchanged and is still ours: it holds the label's box at opacity-0 under the spinner and sets aria-busy. Disabled still keeps its pointer events (focusableWhenDisabled defaults to true), so a tooltip can explain it.

10.2 Alert — intent became variant, and the chrome is composed

BeforeAfter
intent="default"variant="default"
intent="destructive"variant="destructive"
intent="success"variant="success"
intent="warning"variant="warning"
intent="info"variant="info"
variant="strip"gone — add className="items-center gap-2 px-3 py-2"
icon={<Bell />}render the icon as the first child
hideIconrender no icon child
dismissable / onDismiss / dismissLabelyour own Button inside AlertAction, and your own visibility state
livegonerole="alert" is now unconditional
AlertActionsAlertAction (singular)

A status variant now tints the text, not the surface: the card stays bg-card and the ink is the family's -text token. Upstream also ships no default icon per status, so pass one — colour alone is never the signal.

// Before
<Alert intent="destructive" dismissable onDismiss={hide}>
  <AlertTitle>Deploy failed</AlertTitle>
  <AlertDescription>Check the build log.</AlertDescription>
</Alert>

// After
<Alert variant="destructive">
  <OctagonXIcon />
  <AlertTitle>Deploy failed</AlertTitle>
  <AlertDescription>Check the build log.</AlertDescription>
  <AlertAction>
    <Button variant="ghost" size="icon-xs" aria-label="Dismiss" onClick={hide}>
      <XIcon />
    </Button>
  </AlertAction>
</Alert>;

10.3 Badge — three axes became one

BeforeAfter
variant="solid" intent="default"variant="default"
variant="soft" intent="default"variant="secondary"
variant="soft" intent="destructive"variant="destructive"
variant="soft" intent="success"variant="success"
variant="soft" intent="warning"variant="warning"
variant="soft" intent="info"variant="info"
variant="solid" intent="<status>"variant="<status>" — the tinted form; there is no solid tier
variant="outline"variant="outline"
variant="minimal"variant="ghost"
borderedgoneclassName="border-destructive/30" if you want it
size="sm"goneclassName="h-4 px-1.5 text-xs leading-none"
size="md"gone — the default
size="lg"goneclassName="h-6 px-2.5"

badgeVariants is still exported, so a badge-shaped link stays a one-liner.

10.4 Tabs

variant on TabsList is default (upstream's grey pill track) and line. pill and chip are gone, as is TabsTrigger's count badge — compose a Badge inside the trigger instead. There is no loading prop: a tab reveals a panel that is already mounted.

// Before
<Tabs value={tab} onValueChange={setTab}>
  <TabsList variant="pill">
    <TabsTrigger value="all" count={12}>All</TabsTrigger>
    <TabsTrigger value="mine">Mine</TabsTrigger>
  </TabsList>
</Tabs>

// After
<Tabs value={tab} onValueChange={setTab}>
  <TabsList>
    <TabsTrigger value="all">
      All
      <Badge variant="secondary">12</Badge>
    </TabsTrigger>
    <TabsTrigger value="mine">Mine</TabsTrigger>
  </TabsList>
</Tabs>;

orientation="vertical" on Tabs is a layout switch only — upstream writes data-orientation itself rather than forwarding it, so the roving axis stays left and right arrow keys.

10.5 Field composes rather than configures

Field is no longer Base UI's Field.Root with label, description, error and borderless props. It is a plain <div role="group"> with an orientation variant, and every part is a child.

BeforeAfter
<Field label="Email" …><FieldLabel htmlFor="email">Email</FieldLabel> as a child
description="…"<FieldDescription> as a child
error="…"<FieldError> as a child, or <FieldError errors={…} />
borderlessgone — pass the flattening classes yourself
FieldRootField
FieldControlgone — put the control in directly
FieldSuccessgone — render your own <p>
name / validate (Base UI props)gone — validation is your form library's job
invalid statearia-invalid on the control, data-invalid="true" on Field
disabled statedisabled on the control, data-disabled="true" on Field
newFieldTitle, FieldSeparator
// Before
<Field label="Email" description="We never share it." error={errors.email?.message}>
  <FieldControl render={<Input type="email" {...register("email")} />} />
</Field>

// After
<Field data-invalid={errors.email ? "true" : undefined}>
  <FieldLabel htmlFor="email">Email</FieldLabel>
  <Input id="email" type="email" aria-invalid={!!errors.email} {...register("email")} />
  <FieldDescription>We never share it.</FieldDescription>
  <FieldError errors={[errors.email]} />
</Field>;

react-hook-form's register wires straight to the control — there is no Controller indirection, and FieldError takes an errors array so it can dedupe and list several messages.

10.6 The toast manager

toast used to be a callable with named helpers. It is now Base UI's manager object.

BeforeAfter
toast("Saved")toast.add({ title: "Saved" })
toast.success("Saved", { description })toast.add({ title: "Saved", description, type: "success" })
toast.error(…)toast.add({ …, type: "error" })
toast.warning(…) / toast.info(…)toast.add({ …, type: "warning" | "info" })
toast.loading(…)toast.add({ …, type: "loading" })
toast.dismiss(id) / toast.dismiss()toast.close(id)
toast.update(id, options)toast.update(id, options) — unchanged
toast.promise(p, { … })toast.promise(p, { … }) — unchanged
toast.custom(render)gone — pass your own node as title / description
import { toastManager }import { toast }toast is the manager
import { useToast }import { useToastManager }
ToastRoot, ToastPositioner, ToastArrowgoneToast is the root; the viewport positions
ToastProvider position="…"goneclassName on ToastViewport

The automatic priority mapping is gone with the helpers: error and warning used to be promoted to an assertive announcement for you. Pass priority: "high" explicitly when a toast reports a problem the user must notice.

// Before
toast.error("Upload failed", { description: file.name });

// After
toast.add({
  title: "Upload failed",
  description: file.name,
  type: "error",
  priority: "high",
});

Sonner ships alongside Toast — upstream has both. Mount one, not both.

10.7 Overlays

ComponentChange
DialogContentsize and closeLabel removed — retune with a max-w-* utility
DialogTitleBarremoved — compose DialogHeader with DialogTitle and a close Button
DialogDialogOverlay and DialogPortal are now exported parts
SheetContentsize removed
Sheetside moved from the root to SheetContent; the root is Base UI Dialog now
DropdownMenuContentportalProps removed; DropdownMenuPortal is exported instead
TooltipContent, DropdownMenuContent, DropdownMenuSubContentgain an optional container, forwarded to the Base UI portal. Leave it unset for upstream's default. Pass undefined, never null — Base UI reads an explicit null as "not resolved yet" and renders no portal at all
menu items (dropdown, context, menubar)tone="destructive"variant="destructive"
Commandnow cmdk, not a Base UI Combobox build; CommandFooter, CommandLoading and useCommandFilteredItems are gone
Comboboxcomposes upstream's InputGroup; ComboboxInputGroup, ComboboxClear, ComboboxPopupInput, ComboboxStatus, ComboboxGroupLabel, ComboboxChipRemove and useComboboxFilteredItems are gone, and ComboboxLabel, ComboboxSeparator, ComboboxChipsInput and useComboboxAnchor are new
// Before
<Sheet side="right">
  <SheetTrigger render={<Button>Open</Button>} />
  <SheetContent size="md">…</SheetContent>
</Sheet>

// After
<Sheet>
  <SheetTrigger render={<Button>Open</Button>} />
  <SheetContent side="right" className="sm:max-w-md">

  </SheetContent>
</Sheet>;

Drawer is new and replaces nothing. Upstream ships sheet (Dialog-based, side) and drawer (swipe, snap points, nesting, non-modal) as two components. The swipe-and-snap behaviour 0.4.x's Sheet had lives in Drawer now: snapPoints on the root, read and written through the controlled pair snapPoint and onSnapPointChange — Base UI's names, not Vaul's activeSnapPoint.

10.8 Form controls

ComponentRemoved
Inputsize, prefix, suffix — compose InputGroup for an addon
Textareasize
Checkboxsize
RadioGroupItemsize
Switchsize
Slidervariant, thumb
Selectthe md tier (sizes are upstream's now); SelectList is gone and SelectScrollUpButton / SelectScrollDownButton are new
NumberFieldsize; the box is upstream's InputGroup
ChipInputsize; chipInputVariants is deleted with no alias
// Before
<Input size="sm" prefix={<SearchIcon />} placeholder="Search" />

// After
<InputGroup className="h-7">
  <InputGroupAddon align="inline-start">
    <SearchIcon />
  </InputGroupAddon>
  <InputGroupInput placeholder="Search" />
</InputGroup>;

10.9 Navigation and layout

ComponentChange
AppShellmobileBreakpoint and keyboardShortcut removed (upstream's provider has neither)
TablescrollLabel, grid, containerProps removed; cells no longer wrap
Progresssize, trackClassName, indicatorClassName removed; five parts now (section 8.8)
ScrollAreaorientation and scrollbarProps removed from the root; render <ScrollBar orientation="horizontal" /> as a child for a second axis
BreadcrumbBreadcrumbTrail, BreadcrumbCollapsed and BreadcrumbSegment removed — compose the parts
PaginationPaginationPager removed; size removed
NavigationMenuNavigationMenuPanel and NavigationMenuGridLink removed; NavigationMenuIndicator, NavigationMenuPositioner and navigationMenuTriggerStyle are new
EmptyEmptyIllustration and EmptyValue removed; size and intent removed
Avatar, Kbd, Spinnersize removed
Avatarcomposes AvatarImage / AvatarFallback; AvatarBadge and AvatarGroupCount are new
Cardsize="md"size="default"; sm unchanged

10.10 Data and AI/chat

ComponentChange
Attachmentstates are upstream's idle · uploading · processing · error · done — no complete, no disabled; AttachmentProgress and AttachmentDescription's live removed; the md tier removed; gains xs and an AttachmentAction part
Bubble, Message, MarkeranimateIn removed — a thread's entrance animation is the app's
ChartChartGrid removed (use recharts' own CartesianGrid); ChartColorToken removed — ChartConfig takes any colour string; ChartStyle is exported
MediaPlayerControlsportalContainer removed with no replacement prop, and none needed — the transport derives the portal container itself from document.fullscreenElement, and only while that element contains it. Fullscreen control tooltips and the fullscreen settings menu work; delete the prop and pass nothing. VideoPlayer needs no change either

11. What is new

Thirteen items arrive with this release and are worth knowing before you hand-roll one:

aspect-ratio · button-group · calendar · carousel · direction · drawer · input-group · input-otp · menubar · native-select · questionnaire · sonner, plus panel-search, an internal shared row installed as a dependency rather than picked from a list.

Blocks. The registry now serves a hundred of them — upstream's Base UI blocks, 68 chart blocks, and four of ours (app-shell-01, board-01, settings-01, onboarding-01). A block is copy-once: install it, own it, and it is never updated under you.

12. Proving a project is clean

Run each of these from your project root. A clean project returns nothing for all of them. components/ui/ is excluded because stage 4 re-pulls it wholesale.

# 1. Retired and removed component names (compile errors, but catches strings and tests too).
rg -n 'IconButton|iconButtonGeometry|OTPInput|PasswordInput|CheckboxGroup|FieldInline|Segmented|SegmentedItem|SplitButton|ProgressIndicator|OnboardingChecklist|FloatingSurface|MarketingSurface|ComparisonMatrix|FigureFrame|LogoRow|ParticleField|PricingSection|RuledBand|SectionHeader|Testimonial|StaggeredTextReveal' --glob '!components/ui/**'
# 2. Deleted CSS tokens, in class strings and in your own CSS. THE SILENT ONE.
# `--glob` must come BEFORE the `--` terminator, which is what lets the pattern start with a dash.
rg -n --glob '!components/ui/**' -- '--surface-[123]|--alpha-|--opacity-|--size-(xs|sm|md|lg)|--icon-(compact|inline|default|action|feature)|--panel-width-|--layout-(header-height|overlay-max-height)|--z-(raised|overlay|toast)|--shadow-overlay|--radius-(xs|sharp)|--overlay\b|--muted-foreground-faint|--font-family-pixel|--motion-blur|--effect-blur-glass'
# 3. The same tokens as Tailwind utilities (the form you actually wrote).
rg -n 'bg-surface-[123]|text-surface-|border-surface-|text-muted-foreground-faint|shadow-overlay|rounded-sharp|z-\(--z-|/\(--alpha-|opacity-\(--opacity-|h-\(--size-|size-\(--icon-|w-\(--panel-width-' --glob '!components/ui/**'
# 4. Deleted status-colour steps.
rg -n '(destructive|success|warning|info)-subtle(-hover|-active)?\b|(destructive|success|warning|info)-(hover|active)\b|primary-(hover|active)\b|destructive-border' --glob '!components/ui/**'
# 5. Deleted typography role utilities.
rg -n 'text-(h[1-4]|label|label-sm|code|code-sm|mono-label|strong|display-(sm|md|lg|xl))\b' --glob '!components/ui/**'
# 6. Removed @vegastack/design exports.
rg -n 'surfaceInteractive|surfaceInteractiveGroup|fillInteractive|FillTone|fieldControl|fieldControlGroup|selectedChipVariants' --glob '!components/ui/**'
# 7. Removed variant recipes (section 7.2).
rg -n '\b(alert|attachment|attachmentMedia|avatar|avatarGroup|bubble|bubbleReactions|checkbox|chipInput|comboboxInput|comboboxInputGroup|comboboxTrigger|dialogContent|empty|emptyMedia|field|floatingPopup|item|itemMedia|kbd|menuItem|paginationLink|progress|progressIndicator|radioGroup|segmented|segmentedItem|selectTrigger|sheet|sidebarMenuButton|skeleton|spinner|switch|switchThumb)Variants\b' --glob '!components/ui/**'
# 8. Changed props that still type-check in loose spots (JSX spreads, test fixtures).
rg -n 'tone=|intent=|variant="(solid|soft|minimal|strip|pill|chip|cta)"|dismissable|hideIcon|scrollLabel|containerProps|trackClassName|indicatorClassName|portalProps|portalContainer|animateIn|borderless' --glob '!components/ui/**'
# 9. The old toast API.
rg -n 'toast\.(success|error|warning|info|loading|custom|dismiss)\(|toastManager|useToast\b|ToastRoot|ToastPositioner|ToastArrow' --glob '!components/ui/**'
# 10. The <Name>Props aliases upstream does not export.
rg -n 'import \{[^}]*\b(Button|Badge|Alert|Card|Dialog|Sheet|Input|Table|Field|Select|Combobox|Progress|Empty|Kbd|Avatar|Spinner|Toast)[A-Za-z]*Props\b' --glob '!components/ui/**'

If your project is a design-system consumer rather than a plain app, the vegastack-design-audit agent skill runs all of these plus the focus-glow and status-ink checks and reports them with severities — see Agent skills.

13. If you see X, it means Y

The compiler catches the loud half. These are the quiet ones.

What you seeWhat it means
An element that should be a grey well is transparentbg-surface-1 (or -2 / -3) compiled to nothing. Search 12.3, write bg-muted or bg-accent
A hover state does nothing at allSame cause on the hover rung, or a hover:bg-foreground/(--alpha-hover) whose alpha token is gone. Write the literal from 4.2
A control is full-height / has no heighth-(--size-md) compiled to nothing. Write h-8 (4.4)
An icon renders at its intrinsic 24px inside a small buttonsize-(--icon-default) compiled to nothing. Write size-4 — or drop the class: upstream's components size their own icon children
Everything is subtly larger than beforeCorrect. The type scale is stock now — text-base is 16px, not 14px. Section 5
A heading lost its size and is body-weight texttext-h2 compiled to nothing. Write text-xl font-semibold tracking-tight (section 5)
Status text on a tinted background looks washed outYou wrote text-destructive on bg-destructive/10. Use text-destructive-text (section 6)
A status surface is transparentbg-destructive-subtle compiled to nothing. Write bg-destructive/10
A dropdown renders behind the page headerbody { isolation: isolate } from base.css is missing — a granular-import setup that skipped it. Import the preset
A toast fired from a dialog is behind the scrimOne z-50 band and DOM order. Fire it after the dialog closes, or mount the toaster after the dialog
The modal scrim looks much lighter than it used toCorrect. --overlay at 28% became upstream's bg-black/10 with a small backdrop blur
Nothing shows a focus ring when tabbingbase.css was skipped. The focus outline is global, not per-component — this is a WCAG 2.4.7 failure, treat it as ship-blocking
A focused text input shows no outlineCorrect. Text entry tints its border (focus:border-ring/70) instead of drawing an outline
A focused button has a fat haloA stale copied-in component still carrying ring-3 ring-ring/50. Re-pull it (stage 4)
toast(...) silently does nothingEither no VegaStackProvider above the caller, or the old callable form. It is toast.add({ title }) now (10.6)
An error toast is no longer announced urgentlyThe helpers' automatic priority mapping is gone. Pass priority: "high" (10.6)
A standing banner is announced on every page loadAlert is unconditionally role="alert" now and the live prop is gone. Use your own section for standing banners (10.2)
A wide table no longer reaches by keyboardUpstream's scroll container is not a tab stop. Give the wrapper tabIndex={0} and an aria-label yourself
Table rows got taller and text stopped wrappingCorrect. Every cell is whitespace-nowrap; the table scrolls horizontally instead
A tooltip or menu never appears after you wired a containerYou passed null. Base UI reads an explicit null as "the container has not resolved yet" and renders no portal. A ref or state that starts empty must start as undefined, not null
check-updates says up to date but the version number movedNot a bug. Status is by content hash, not version — see Troubleshooting

14. A full file, migrated end to end

A realistic settings panel, before and after. It touches Button's two-axis API, IconButton, Badge's three axes, Alert's intent and dismiss, CheckboxGroup, the configured Field, the role type scale, the surface ladder, the alpha ladder and the old toast helpers — which is roughly what a real file hits.

Before (0.4.x)

"use client";

import * as React from "react";
import { Trash2Icon, XIcon } from "lucide-react";

import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { CheckboxGroup, Checkbox } from "@/components/ui/checkbox-group";
import { Field, FieldControl } from "@/components/ui/field";
import { IconButton } from "@/components/ui/icon-button";
import { Input } from "@/components/ui/input";
import { toast } from "@/components/ui/toast";

const CHANNELS = ["email", "sms", "push"];

export function NotificationSettings({ plan }: { plan: string }) {
  const [name, setName] = React.useState("");
  const [channels, setChannels] = React.useState<string[]>(["email"]);
  const [error, setError] = React.useState<string>();
  const [saving, setSaving] = React.useState(false);

  async function save() {
    if (!name.trim()) {
      setError("A display name is required.");
      return;
    }
    setSaving(true);
    try {
      await api.saveSettings({ name, channels });
      toast.success("Settings saved");
    } catch {
      toast.error("Could not save settings");
    } finally {
      setSaving(false);
    }
  }

  return (
    <section className="rounded-lg bg-surface-1 p-4">
      <header className="mb-3 flex items-center justify-between">
        <div className="flex items-center gap-2">
          <h2 className="text-h2">Notifications</h2>
          <Badge variant="soft" intent="info" size="sm">
            {plan}
          </Badge>
        </div>
        <IconButton aria-label="Reset to defaults" variant="ghost" size="sm">
          <Trash2Icon />
        </IconButton>
      </header>

      <Alert intent="warning" dismissable className="mb-3">
        <AlertTitle>Digest is paused</AlertTitle>
        <AlertDescription>
          Resume it to receive the weekly summary.
        </AlertDescription>
      </Alert>

      <Field
        label="Display name"
        description="Shown on every notification."
        error={error}
      >
        <FieldControl
          render={
            <Input
              size="md"
              value={name}
              onChange={(event) => setName(event.target.value)}
            />
          }
        />
      </Field>

      <CheckboxGroup
        className="mt-4"
        aria-label="Channels"
        value={channels}
        onValueChange={setChannels}
      >
        {CHANNELS.map((channel) => (
          <Checkbox key={channel} value={channel} size="md">
            <span className="text-label">{channel}</span>
          </Checkbox>
        ))}
      </CheckboxGroup>

      <footer className="mt-4 flex justify-end gap-2 border-t border-border/(--alpha-border) pt-3">
        <Button variant="ghost" tone="neutral">
          Cancel
        </Button>
        <Button variant="solid" tone="neutral" loading={saving} onClick={save}>
          Save changes
        </Button>
      </footer>
    </section>
  );
}

After (0.5.0)

"use client";

import * as React from "react";
import { Trash2Icon, TriangleAlertIcon, XIcon } from "lucide-react";

import {
  Alert,
  AlertAction,
  AlertDescription,
  AlertTitle,
} from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
  Field,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLabel,
  FieldLegend,
  FieldSet,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { toast } from "@/components/ui/toast";

const CHANNELS = ["email", "sms", "push"];

export function NotificationSettings({ plan }: { plan: string }) {
  const [name, setName] = React.useState("");
  const [channels, setChannels] = React.useState<string[]>(["email"]);
  const [error, setError] = React.useState<string>();
  const [saving, setSaving] = React.useState(false);
  const [digestNoticeOpen, setDigestNoticeOpen] = React.useState(true);

  const toggleChannel = (channel: string, on: boolean) =>
    setChannels((prev) =>
      on ? [...prev, channel] : prev.filter((value) => value !== channel),
    );

  async function save() {
    if (!name.trim()) {
      setError("A display name is required.");
      return;
    }
    setSaving(true);
    try {
      await api.saveSettings({ name, channels });
      toast.add({ title: "Settings saved", type: "success" });
    } catch {
      toast.add({
        title: "Could not save settings",
        type: "error",
        priority: "high",
      });
    } finally {
      setSaving(false);
    }
  }

  return (
    <section className="rounded-lg bg-muted p-4">
      <header className="mb-3 flex items-center justify-between">
        <div className="flex items-center gap-2">
          <h2 className="text-xl font-semibold tracking-tight">
            Notifications
          </h2>
          <Badge variant="info" className="h-4 px-1.5 text-xs leading-none">
            {plan}
          </Badge>
        </div>
        <Button variant="ghost" size="icon-sm" aria-label="Reset to defaults">
          <Trash2Icon />
        </Button>
      </header>

      {digestNoticeOpen ? (
        <Alert variant="warning" className="mb-3">
          <TriangleAlertIcon />
          <AlertTitle>Digest is paused</AlertTitle>
          <AlertDescription>
            Resume it to receive the weekly summary.
          </AlertDescription>
          <AlertAction>
            <Button
              variant="ghost"
              size="icon-xs"
              aria-label="Dismiss"
              onClick={() => setDigestNoticeOpen(false)}
            >
              <XIcon />
            </Button>
          </AlertAction>
        </Alert>
      ) : null}

      <Field data-invalid={error ? "true" : undefined}>
        <FieldLabel htmlFor="display-name">Display name</FieldLabel>
        <Input
          id="display-name"
          value={name}
          aria-invalid={!!error}
          onChange={(event) => setName(event.target.value)}
        />
        <FieldDescription>Shown on every notification.</FieldDescription>
        <FieldError>{error}</FieldError>
      </Field>

      <FieldSet className="mt-4">
        <FieldLegend>Channels</FieldLegend>
        <FieldGroup data-slot="checkbox-group">
          {CHANNELS.map((channel) => (
            <Field key={channel} orientation="horizontal">
              <Checkbox
                id={channel}
                checked={channels.includes(channel)}
                onCheckedChange={(on) => toggleChannel(channel, on === true)}
              />
              <FieldLabel htmlFor={channel}>{channel}</FieldLabel>
            </Field>
          ))}
        </FieldGroup>
      </FieldSet>

      <footer className="mt-4 flex justify-end gap-2 border-t border-border pt-3">
        <Button variant="ghost">Cancel</Button>
        <Button loading={saving} onClick={save}>
          Save changes
        </Button>
      </footer>
    </section>
  );
}

Eleven changes, all of them mechanical once you know the rule:

  1. bg-surface-1bg-muted.
  2. text-h2text-xl font-semibold tracking-tight.
  3. text-label → dropped; FieldLabel carries the weight itself.
  4. border-border/(--alpha-border)border-border (the token is already the derived hairline).
  5. IconButton size="sm"Button size="icon-sm", keeping aria-label.
  6. Badge variant="soft" intent="info" size="sm"Badge variant="info" plus a size className.
  7. Alert intent="warning" dismissableAlert variant="warning" with an icon child, an AlertAction close button and your own visibility state.
  8. Field label/description/error → composed FieldLabel, FieldDescription, FieldError, with data-invalid on the Field and aria-invalid on the control.
  9. Input size="md" → no size prop.
  10. CheckboxGroupFieldSet + FieldGroup + one Field per option, with the state folded by hand.
  11. toast.success / toast.errortoast.add({ …, type }), with an explicit priority: "high" on the error.

15. Why this happened

The system began as shadcn and drifted: 168 deliberate deviations accumulated, each defensible alone, and together they made components that read as worse than the upstream they came from. This release reverses the accumulation rather than continuing to patch it. Every component's docs page ends in a Deviations section naming the decision IDs behind its own patch, so any single difference from shadcn is traceable in one click.

On this page

1. How an agent should use this page2. What changes visually3. The upgrade procedureStage 1 — packagesStage 2 — make it compileStage 3 — find what the compiler cannot seeStage 4 — re-pull the copied-in componentsStage 5 — look at it4. Deleted tokens, with an exact replacement for each4.1 Surfaces and scrim4.2 The alpha ladder (19 entries)4.3 The opacity ladder (4 entries)4.4 Sizes, icons, layout widths, z-index, radius4.5 Status colour steps4.6 Marketing tokens4.7 What is kept and still ours5. Typography: the whole scale shifted one stepThe role utilities are deleted6. Status colour now has two inks7. Removed exports7.1 From @vegastack/design7.2 Variant recipes that components no longer export7.3 The <Name>Props type aliases8. The ten retired components8.1 IconButtonButton at an icon size8.2 OTPInputInputOTP8.3 PasswordInput → an InputGroup composition8.4 CheckboxGroupFieldSet + Checkbox8.5 FieldInlineEditableCell8.6 Segmented → a joined ToggleGroup8.7 SplitButton → a ButtonGroup composition8.8 ProgressIndicatorProgress + Spinner8.9 OnboardingChecklist → the onboarding-01 block8.10 FloatingSurface — internal only9. Removed outright: the marketing layer10. API changes, component by component10.1 Button — variant × tone became upstream's flat variant10.2 Alert — intent became variant, and the chrome is composed10.3 Badge — three axes became one10.4 Tabs10.5 Field composes rather than configures10.6 The toast manager10.7 Overlays10.8 Form controls10.9 Navigation and layout10.10 Data and AI/chat11. What is new12. Proving a project is clean13. If you see X, it means Y14. A full file, migrated end to endBefore (0.4.x)After (0.5.0)15. Why this happened