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
- Read sections 2 and 3 to know what you are doing and in what order.
- Run the upgrade stage by stage (section 3). Verify after each stage; do not batch.
- For each compile error, find the component in sections 7 to 10 and apply the stated rewrite.
- Run every search in section 12. A clean project returns nothing for all of them.
- 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 intoglobals.cssmakes 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--overwritedestroys 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 control | One 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 rows | A 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 press | muted 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 lint | A hover with no pressed step is upstream's own behaviour and is fine |
Radius capped at rounded-lg; rounded-xl banned | Radius derives from one --radius (0.625rem); a card and a dialog wear rounded-xl |
One sanctioned shadow (--shadow-overlay), everything else flat | shadow-sm / shadow-md / shadow-lg, per component, as shadcn uses them |
A 14px product type scale bound onto every text-* utility | Tailwind'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 errors | Ordinary 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% alpha | Upstream'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 underline | Tabs' default list is upstream's grey pill track; line is the alternative |
| Table body cells wrapped | Every 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
Tablewraps itself in a plainoverflow-x-autodiv. If a table of yours is genuinely wide, give the wrapper atabIndex={0}and anaria-labelin 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-50band and DOM order decides; the<Toaster />mounts first. Raise the toast after the dialog closes, or mount your toaster after the dialog. Alertis alwaysrole="alert". Upstream sets it unconditionally, so a statically rendered banner is now an assertive live region. The oldliveprop, 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.0If 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 --noEmitEvery 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 itDelete 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 startWalk 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
| Deleted | Was | Write instead |
|---|---|---|
--surface-1 | the rest fill / well / track | bg-muted |
--surface-2 | the hover rung | bg-accent (pair with text-accent-foreground) |
--surface-3 | the pressed / selected rung | bg-accent — there is no third rung; selection uses data-* now |
--overlay | the modal scrim, 28% alpha | nothing — DialogOverlay paints upstream's bg-black/10 |
--shadow-overlay | the one sanctioned shadow | shadow-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.
| Deleted | Write 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)
| Deleted | Was | Write instead |
|---|---|---|
--opacity-track | 25% | opacity-25 |
--opacity-dim | 50% | opacity-50 |
--opacity-hint-soft | 60% | opacity-60 |
--opacity-hint | 70% | 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
| Deleted | Was | Write instead |
|---|---|---|
--size-xs | 1.5rem | h-6 |
--size-sm | 1.75rem | h-7 |
--size-md | 2rem | h-8 |
--size-lg | 2.5rem | h-10 |
--icon-compact | 0.75rem | size-3 |
--icon-inline | 0.875rem | size-3.5 |
--icon-default | 1rem | size-4 |
--icon-action | 1.25rem | size-5 |
--icon-feature | 1.5rem | size-6 |
--panel-width-sm | 14rem | w-56 |
--panel-width-md | 18rem | w-72 |
--panel-width-lg | 20rem | w-80 |
--layout-header-height | 3.5rem | h-14 |
--layout-overlay-max-height | calc(100dvh - 16rem) | max-h-[calc(100dvh-16rem)] |
--sidebar-width | 15rem | nothing — SidebarProvider now sets 16rem itself |
--sidebar-width-icon | 3rem | nothing — set by SidebarProvider |
--sidebar-width-mobile | 18rem | nothing — set by SidebarProvider |
--z-raised | 10 | z-10 |
--z-overlay | 50 | z-50 |
--z-toast | 60 | z-50 — there is one overlay band now |
--radius-xs | 0.125rem | rounded-xs |
--radius-sharp | 2px | nothing — 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.
| Deleted | Write instead |
|---|---|
--destructive-subtle, --success-subtle, --warning-subtle, --info-subtle | the family at /10: bg-destructive/10 |
--<family>-subtle-hover | hover:bg-destructive/20 |
--<family>-subtle-active | active: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-active | hover:bg-primary/80 |
--destructive-border | border-destructive |
--muted-foreground-faint | text-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 wrote | Rendered before | Renders now | To keep the old size, write |
|---|---|---|---|
text-xs | 11px | 12px | text-[11px] |
text-sm | 12px | 14px | text-xs |
text-base | 14px | 16px | text-sm |
text-lg | 16px | 18px | text-base |
text-xl | 18px | 20px | text-lg |
text-2xl | 20px | 24px | text-xl |
text-3xl | 24px | 30px | text-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 utility | Was | Write instead |
|---|---|---|
text-h1 | 24px / 32, w400, -0.02em | text-2xl font-semibold tracking-tight |
text-h2 | 20px / 28, w400, -0.015em | text-xl font-semibold tracking-tight |
text-h3 | 18px / 24, w400, -0.01em | text-lg font-semibold |
text-h4 | 16px / 22, w500 | text-base font-medium |
text-label | 14px / 20, w500 | text-sm font-medium |
text-strong | 14px / 20, w600 | text-sm font-semibold |
text-label-sm | 12px / 16, w500 | text-xs font-medium |
text-code | 13px / 20, mono | font-mono text-sm |
text-code-sm | 12px / 16, mono | font-mono text-xs |
text-mono-label | 12px / 16, mono, +0.05em | font-mono text-xs tracking-wider |
text-display-sm | 32px / 36, -0.04em | text-4xl tracking-tighter (36px) or text-[2rem]/9 |
text-display-md | 40px / 44, -0.045em | text-5xl tracking-tighter (48px) or text-[2.5rem]/11 |
text-display-lg | 56px / 60, -0.05em | text-6xl tracking-tighter (60px) or text-[3.5rem]/15 |
text-display-xl | 72px / 76, -0.06em | text-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>-foregroundis the ink on the solid fill:bg-destructive text-destructive-foreground.--<family>-textis the ink on the page, and on the family's own/10–/30tint: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.
| Removed | What it was | Replace with |
|---|---|---|
surfaceInteractive | hover:bg-surface-2 active:bg-surface-3 | hover:bg-accent hover:text-accent-foreground |
surfaceInteractiveGroup | the same, keyed off a group/wash | group-hover/wash:bg-accent |
fillInteractive | a per-tone hover / press map | the fill at an alpha: hover:bg-primary/80 |
FillTone | its tone union | nothing — the tone axis is gone |
fieldControl | the shared text-entry chrome | compose upstream's Input / Textarea / InputGroup instead of re-deriving it |
fieldControlGroup | its wrapper twin | InputGroup |
selectedChipVariants | the raised-chip-on-a-track recipe | upstream'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 IconButton → Button at an icon size
IconButton | Button |
|---|---|
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 OTPInput → InputOTP
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.
OTPInput | InputOTP 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) |
separatorClassName | className on InputOTPSeparator |
slotClassName | className on each InputOTPSlot |
size="sm" | "md" | "lg" | gone — a slot is size-8; override with className on the slot |
value / defaultValue | same |
onValueChange(v, details) | onChange(v) — there is no Base UI event-details object |
onValueComplete(v) | onComplete(v) |
aria-invalid | put 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
PasswordInput | InputGroup composition |
|---|---|
value / onChange | on InputGroupInput |
revealLabel / hideLabel | aria-label on the toggle Button, swapped on visible |
defaultVisible | your own useState |
requirements={[…]} | your own list under the field (each row is text + an icon) |
strength | gone — 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 CheckboxGroup → FieldSet + Checkbox
CheckboxGroup | Upstream composition |
|---|---|
value / defaultValue | your own useState<string[]> |
onValueChange(next) | per-Checkbox onCheckedChange, folded into that state |
allValues={[…]} + parent checkbox | one Checkbox whose checked is "indeterminate" when the set is mixed |
<Checkbox value="x" /> children | Field rows with orientation="horizontal", each a Checkbox and a FieldLabel |
aria-label on the group | FieldLegend 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 FieldInline → EditableCell
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.
FieldInline | EditableCell |
|---|---|
value | value |
onCommit(next) | onCommit(next) — may now return a promise, which engages the saving / saved / error indicator |
placeholder | editor={{ type: "text", placeholder }} |
label | label |
aria-label / aria-labelledby | label (the cell resolves label, then placeholder, then a generic fallback) |
editing / onEditingChange | editing / onEditingChange |
disabled / readOnly | same |
tabIndex={-1} for a grid host | focusMode="managed" |
ref | ref — 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 |
error | gone — wrap the cell in Field and render FieldError |
borderless | gone — 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
Segmented | ToggleGroup |
|---|---|
value: string | value: string[] — pass [value] |
onValueChange(next: string) | onValueChange(next: string[]) — read next[0] |
| always exactly one selected | not enforced — ignore an empty next to keep one active |
size="sm" | "md" | "lg" | size on the group (sm / default / lg) |
| joined track, always | spacing={0} |
<SegmentedItem value> | <ToggleGroupItem value> |
| vertical: not supported | orientation="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
SplitButton | ButtonGroup composition |
|---|---|
children (the default label) | the first Button |
onClick | that Button's onClick |
actions={[{ label, onSelect }]} | DropdownMenuItem children |
menuLabel | aria-label on the trigger Button |
variant / disabled / loading | on 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 ProgressIndicator → Progress + Spinner
ProgressIndicator | Replacement |
|---|---|
value / max | <Progress value={…} max={…} /> — same props |
aria-label | aria-label on Progress |
variant="inline-value" | ProgressLabel + ProgressValue inside Progress |
indeterminate | <Spinner /> |
shape="circle" | "squircle" | gone — there is no radial form |
segments={n} / segmentsFill | gone — a single determinate bar |
size="xs" … "lg" | className on ProgressTrack (h-1 is the default) |
trackClassName | className on ProgressTrack |
indicatorClassName | className 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.
OnboardingChecklist | In the block |
|---|---|
title, done, total | literals in the copied page; done is derived from the step array |
collapsed / defaultCollapsed | React.useState in the copied component, if you want it |
onCollapsedChange | the same state setter |
collapseLabel / expandLabel | the strings in the copied markup |
| the determinate bar | Progress, 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.
| Before | After |
|---|---|
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 IconButton | size="icon" | "icon-xs" | "icon-sm" | "icon-lg" |
data-tone, data-shape | gone, 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
| Before | After |
|---|---|
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 |
hideIcon | render no icon child |
dismissable / onDismiss / dismissLabel | your own Button inside AlertAction, and your own visibility state |
live | gone — role="alert" is now unconditional |
AlertActions | AlertAction (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
| Before | After |
|---|---|
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" |
bordered | gone — className="border-destructive/30" if you want it |
size="sm" | gone — className="h-4 px-1.5 text-xs leading-none" |
size="md" | gone — the default |
size="lg" | gone — className="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.
| Before | After |
|---|---|
<Field label="Email" …> | <FieldLabel htmlFor="email">Email</FieldLabel> as a child |
description="…" | <FieldDescription> as a child |
error="…" | <FieldError> as a child, or <FieldError errors={…} /> |
borderless | gone — pass the flattening classes yourself |
FieldRoot | Field |
FieldControl | gone — put the control in directly |
FieldSuccess | gone — render your own <p> |
name / validate (Base UI props) | gone — validation is your form library's job |
| invalid state | aria-invalid on the control, data-invalid="true" on Field |
| disabled state | disabled on the control, data-disabled="true" on Field |
| new | FieldTitle, 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.
| Before | After |
|---|---|
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, ToastArrow | gone — Toast is the root; the viewport positions |
ToastProvider position="…" | gone — className 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
| Component | Change |
|---|---|
DialogContent | size and closeLabel removed — retune with a max-w-* utility |
DialogTitleBar | removed — compose DialogHeader with DialogTitle and a close Button |
Dialog | DialogOverlay and DialogPortal are now exported parts |
SheetContent | size removed |
Sheet | side moved from the root to SheetContent; the root is Base UI Dialog now |
DropdownMenuContent | portalProps removed; DropdownMenuPortal is exported instead |
TooltipContent, DropdownMenuContent, DropdownMenuSubContent | gain 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" |
Command | now cmdk, not a Base UI Combobox build; CommandFooter, CommandLoading and useCommandFilteredItems are gone |
Combobox | composes 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
| Component | Removed |
|---|---|
Input | size, prefix, suffix — compose InputGroup for an addon |
Textarea | size |
Checkbox | size |
RadioGroupItem | size |
Switch | size |
Slider | variant, thumb |
Select | the md tier (sizes are upstream's now); SelectList is gone and SelectScrollUpButton / SelectScrollDownButton are new |
NumberField | size; the box is upstream's InputGroup |
ChipInput | size; 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
| Component | Change |
|---|---|
AppShell | mobileBreakpoint and keyboardShortcut removed (upstream's provider has neither) |
Table | scrollLabel, grid, containerProps removed; cells no longer wrap |
Progress | size, trackClassName, indicatorClassName removed; five parts now (section 8.8) |
ScrollArea | orientation and scrollbarProps removed from the root; render <ScrollBar orientation="horizontal" /> as a child for a second axis |
Breadcrumb | BreadcrumbTrail, BreadcrumbCollapsed and BreadcrumbSegment removed — compose the parts |
Pagination | PaginationPager removed; size removed |
NavigationMenu | NavigationMenuPanel and NavigationMenuGridLink removed; NavigationMenuIndicator, NavigationMenuPositioner and navigationMenuTriggerStyle are new |
Empty | EmptyIllustration and EmptyValue removed; size and intent removed |
Avatar, Kbd, Spinner | size removed |
Avatar | composes AvatarImage / AvatarFallback; AvatarBadge and AvatarGroupCount are new |
Card | size="md" → size="default"; sm unchanged |
10.10 Data and AI/chat
| Component | Change |
|---|---|
Attachment | states 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, Marker | animateIn removed — a thread's entrance animation is the app's |
Chart | ChartGrid removed (use recharts' own CartesianGrid); ChartColorToken removed — ChartConfig takes any colour string; ChartStyle is exported |
MediaPlayerControls | portalContainer 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 see | What it means |
|---|---|
| An element that should be a grey well is transparent | bg-surface-1 (or -2 / -3) compiled to nothing. Search 12.3, write bg-muted or bg-accent |
| A hover state does nothing at all | Same 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 height | h-(--size-md) compiled to nothing. Write h-8 (4.4) |
| An icon renders at its intrinsic 24px inside a small button | size-(--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 before | Correct. The type scale is stock now — text-base is 16px, not 14px. Section 5 |
| A heading lost its size and is body-weight text | text-h2 compiled to nothing. Write text-xl font-semibold tracking-tight (section 5) |
| Status text on a tinted background looks washed out | You wrote text-destructive on bg-destructive/10. Use text-destructive-text (section 6) |
| A status surface is transparent | bg-destructive-subtle compiled to nothing. Write bg-destructive/10 |
| A dropdown renders behind the page header | body { 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 scrim | One 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 to | Correct. --overlay at 28% became upstream's bg-black/10 with a small backdrop blur |
| Nothing shows a focus ring when tabbing | base.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 outline | Correct. Text entry tints its border (focus:border-ring/70) instead of drawing an outline |
| A focused button has a fat halo | A stale copied-in component still carrying ring-3 ring-ring/50. Re-pull it (stage 4) |
toast(...) silently does nothing | Either 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 urgently | The helpers' automatic priority mapping is gone. Pass priority: "high" (10.6) |
| A standing banner is announced on every page load | Alert 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 keyboard | Upstream'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 wrapping | Correct. Every cell is whitespace-nowrap; the table scrolls horizontally instead |
A tooltip or menu never appears after you wired a container | You 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 moved | Not 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:
bg-surface-1→bg-muted.text-h2→text-xl font-semibold tracking-tight.text-label→ dropped;FieldLabelcarries the weight itself.border-border/(--alpha-border)→border-border(the token is already the derived hairline).IconButton size="sm"→Button size="icon-sm", keepingaria-label.Badge variant="soft" intent="info" size="sm"→Badge variant="info"plus a sizeclassName.Alert intent="warning" dismissable→Alert variant="warning"with an icon child, anAlertActionclose button and your own visibility state.Field label/description/error→ composedFieldLabel,FieldDescription,FieldError, withdata-invalidon theFieldandaria-invalidon the control.Input size="md"→ no size prop.CheckboxGroup→FieldSet+FieldGroup+ oneFieldper option, with the state folded by hand.toast.success/toast.error→toast.add({ …, type }), with an explicitpriority: "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.