Things that are true about this repo and are expensive to rediscover. Written for whoever (or whatever) picks the work up next.
reference/ui/ is a clone of shadcn-ui/ui.
It is gitignored — it is ~150 MB and not ours to vendor — but it is the source
of truth for every visual decision here.
Look values up there. Do not guess at colours, paddings, radii or type. Several rounds of this project's history were spent fixing values that were guessed rather than read.
| What | Where |
|---|---|
| Per-style component CSS (the eight styles) | apps/v4/registry/styles/style-{vega,nova,maia,lyra,mira,luma,sera,rhea}.css |
| Base colour palettes, OKLCH, light + dark | apps/v4/registry/themes.ts |
| The app's own tokens | apps/v4/app/globals.css |
| Component sources | apps/v4/registry/new-york-v4/ui/*.tsx |
The /create theme editor we mirror |
apps/v4/app/(app)/(create)/ |
| Its individual pickers | apps/v4/app/(app)/(create)/components/*.tsx |
The style CSS files are ~1670 lines each of .cn-<component> { @apply ... }
blocks. To compare a component across all eight styles, parse the blocks and
diff them — 243 of the 407 classes differ between styles, so eyeballing one
file tells you very little.
Tailwind conversions used throughout:
- Spacing: 1 unit = 4px.
px-2.5is 10,h-9is 36,p-[3px]is a literal 3. - Type:
text-xs12/16,text-sm14/20,text-base16/24,text-lg18/28. - Leading:
none1,snug1.375,normal1.5,relaxed1.625. - Tracking is in ems:
wide.025,wider.05,widest.1 — multiply by the font size to get Flutter's logical-pixelletterSpacing. ring-Nis a box-shadow at zero offset, so a ring has no gap between it and the element.- OKLCH → sRGB needs a real conversion (Oklab matrices + the sRGB transfer
function); there is a converter in this project's history, and the values it
produces are pinned by
test/src/theme/color_scheme/shadcn_tokens_test.dart.
The flow is: ShadThemeData factory → a ShadThemeVariant → per-component
themes. Understanding this order prevents most theming bugs.
- A variant is constructed from a colour scheme, a radius, a text theme, a
ShadStyleTokensand aShadSpacing, and bakes all of them into the component themes it returns. Change one of its inputs and the variant has to be rebuilt —ShadThemeVariant.rebuild(...)exists for exactly that, and the factory calls it when a caller passes both a variant and a different radius, scheme or text theme. Where the caller gives no explicit value, the variant's own wins, so the two can never disagree. ShadThemeData.textThemecomes fromvariant.textTheme(), not from the merged input. That is what applies the style's text roles tosmall,muted,p,listandlarge. Reading the raw input here once made a style change resize components but leave every font size alone.copyWithforwards the already-resolved component themes, so it cannot re-derive them.copyWith(radius: X)moves the theme'sradiusfield but not the components'. Rebuild the theme instead.- There are two variants, but no longer two copies:
ShadDefaultThemeNoSecondaryBorderVariantsubclassesShadDefaultThemeVariantand overrides only the focus treatment (a 2px innerfocusedBorderrecolour plusfocusReserveinsets, instead of the outward ring). Fix things in the parent; only touch the subclass when the change is about focus geometry. - Menu appearance is a theme-level feature:
ShadThemeDatatakesmenuColorScheme(shadcn's "Inverted" — pass the dark counterpart scheme; the reference toggles thedarkclass on menu surfaces, so in dark mode inverted is a no-op),menuTranslucent(popover/70+ backdrop blur,foreground/10row highlights) andmenuAccent(bold = the scheme's accent pair is rewritten with its primary pair before derivation). The example editor passes these through; it no longer derives menu colours itself.
ShadAnimatedTheme is an ImplicitlyAnimatedWidget: if ShadThemeData != the
previous one, it starts a 200 ms lerp across ~54 component themes on every
rebuild. Anything that breaks value equality — a method tear-off in ==, a
non-const Widget in a theme field, a List compared by identity — silently
costs a full re-animation per frame. test/src/theme/theme_data_equality_test.dart
locks the chain; add to it rather than trusting a local check.
ShadRadii— thenone/sm/md/lg/xl/2xl/4xl/fullscale, derived fromShadThemeData.radius(which is the md step). At the default 8: 6, 8, 10, 14, 16, 32.ShadStyleTokens— one of shadcn's eight styles as 65 measured fields: radii per surface class, ring width/opacity, border opacities, control and field metrics, selection-control dimensions, surface padding, shadows, and sixShadTextRoles (title,label,body,caption,overline,field).ShadSpacing— the--spacingunit (4px default).- Metrics shadcn writes in spacing units (
h-9,px-2.5) are stored in the pixels they render at the default step and are passed through the variant'sscaled()so they followspacing.step. Bracketed literals (h-[18.4px],rounded-[4px],p-[3px]) are not scaled. Keep that distinction when adding a token. uppercaseon a text role is not applied automatically:TextStylehas no text-transform, and every Shad component takes a caller's widget rather than a string.role.applyCase(text)is the escape hatch.- Icon sizes inside buttons are a token, not the caller's problem.
.cn-buttoncarries[&_svg:not([class*='size-'])]:size-4, so the button hands 16 down through itsIconTheme; without that Flutter'sIconThemeData.fallback()renders every icon at 24, which is what made toolbars look wrong and forcedsize: 16on call sites everywhere. The:notis the reason it is only a default — anIconwith its ownsizestill wins. Text buttons and icon buttons are sized separately (buttonIconSize{,Sm,Lg}vsiconButtonIconSize{,Sm,Lg}): the reference's.cn-button-size-icon-smkeeps 16 where.cn-button-size-smdrops to 14. Onlymirafollows the step all the way down and onlyserahas asize-3.5base;ShadButtonSizesThemecarriesiconSm/iconLgslots so an icon button never borrows a text button's metrics.
- Fields, checkboxes, radios and OTP slots outline with
--input; cards, popovers, dialogs, menus and toasts with a wash of their own foreground (ring-foreground/10). Not--border. The soft styles vary it per surface and per brightness —luma/rheaare/5light/10dark,serais/5on cards but/10on menus — which is why the tokens carry light/dark pairs for card, surface and dialog opacities. - The
/createstyles changed the button variants from classic shadcn: ghost/outline hover withbg-muted+text-foreground(not accent), destructive is a soft/10tint, secondary hover is a 5% foreground mix, and command items highlight withbg-muted. Menu items still usefocus:bg-accent focus:text-accent-foreground— with every descendant (icons, shortcuts) following the foreground. - Dark
--borderand--inputare translucent whites (10% and 15%). This matters: an opaque grey hairline disappears against a card of the same value. - The dark card and popover are a step lighter than the page.
- Overlays are
bg-black/10withbackdrop-blur-xsin both modes — the blur is what lets the tint stay that light. ShadSlateColorSchemeandShadGrayColorSchemeare v3 palettes that shadcn no longer ships. They are kept for compatibility — and slate is still the package default — whileneutral,stoneandzincare generated fromthemes.ts. Regenerating slate against v4 is an open question.
- CSS heights are border-box;
Containeradds its decoration's border as padding around the child (DecoratedBoxdoes not — the menubar strip relied on one and came out 2px short). A height constraint applied inside aShadDecoratorcomes out2 × bordertoo tall — that made outline buttons and toggles 2px taller than their borderless variants and every control 4px taller in the no-secondary-border variant (focus reserve). Size constraints go outside the decorator;test/src/components/control_heights_test.dartlocks input, select trigger, buttons, toggles, date/time picker, OTP slots, the menubar and the calendar's geometry (cell size, nav buttons, caption dropdowns —calendarCellSize/calendarCellRadius/calendarPadding/calendarCaptionHeighttokens) to the style values in both variants. Calendar tests use single-glyph captions and small day text: the test font's em-wide glyphs wrap or overflow inside cells that fit real fonts comfortably. Related: the select trigger'spy-2never acts in the reference (its height is pinned bydata-[size]:h-*), so there is noselectPaddingYtoken — onlyselectPaddingXandShadSelectTheme.minHeight. The time picker has no reference counterpart; its fields deliberately borrow the input's text role, decoration and height rather than carrying their own metrics. Borderrequires colour-uniform sides to paint a radius, andBorderSide.nonecarries opaque black.ShadBorderSide.toBorderSide()therefore keeps its colour at zero width. Any component that draws only some of its sides (the OTP strip) depends on this.- There is no z-index in a
Row. shadcn's focused OTP slot usesz-10to let its ring overlap its neighbours; in Flutter the next sibling paints over the ring's edge instead. The ring is still drawn outside the slot (slots are transparent, so only their hairlines cross it); don't move it inside — that reads as a different control from every other field. - The portal's overlay is nudged after it is anchored, and anything drawn
against the anchor has to follow.
ShadPositionDelegateruns Flutter'spositionDependentBox, which clamps the overlay into the viewport at layout time — so a tooltip near the window edge is slid inward while its arrow, aCenterinside the bubble, stayed put and pointed at empty space. The portal now computes the same placement in_calculatePosition(throughShadPositionDelegate.positionFor, so the two cannot drift apart) and publishes the displacement asShadPortalPlacement.shift; the arrow subtracts it, clamped to keep the bubble's corner radius plus its own half-diagonal clear of each end. Doing this at paint time instead (localToGlobalfrom the arrow) looks simpler and is wrong: the entranceScaleEffectwraps the arrow, so the measurement moves while the tooltip animates in. - CSS clips outer box-shadows to outside the border box, and Flutter cannot
express that with a
BoxShadow.BlurStyle.normalpaints the shadow behind the whole box, which shows through any transparent or translucent fill as a grey wash;BlurStyle.outercuts the hole out ofrect.shift(offset).inflate(spreadRadius)— the shadow's rectangle, not the element's — so with any offset or spread the shadow disappears next to the element and then restarts with a hard edge a few pixels out. Ashadow-lgdialog lost the 7px directly beneath it that way. Outer shadows therefore go throughShadShadowDecoration(lib/src/utils/shadow.dart), which blurs normally and clips to the region outside the wrapped decoration's owngetClipPath. Never put an outward shadow in aBoxDecoration.boxShadowhere — useShadShadowDecoration.box(...), which takes the same arguments.test/src/utils/shadow_test.dartlocks the profile (monotone falloff, nothing inside the element, a crisp hairline for the spread-only shadows used as outlines) by rasterising and sampling; it flipsdebugDisableShadowsoff, which flutter_test otherwise leaves on. Tests that introspect a component's fill or border go throughtest/decoration_finders.dartto look past the wrapper. ShadButtoncentres its label (TextAlign.centerin its DefaultTextStyle). Anything button-based that should read as a row — menu items — must mergeTextAlign.startback in below the button.ShadBorder.mergetakes the other'sradiusandoffsetunconditionally. An override that sets only the radius silently drops the offset — which collapses a focus ring onto the element's own border.ShadOutwardBorderPainterinflates the rect byoffsetand strokes inside it, sooffset == widthputs the stroke flush against the element. That is how the shadcn ring's "no gap" is reproduced.- A
Positionedchild does not contribute to aStack's size. The slider's thumb is positioned, so the slider has to reservemax(track, thumb)itself or it lays out at track height and paints over its neighbours. - A widget with no intrinsic height stretches when its parent hands down tight
constraints.
ShadKbdtakes an explicit height and anAlign(heightFactor: 1)so a key cap stays key-shaped inside a button's row. - Fixed-size boxes around text (OTP slots) must multiply by
MediaQuery.textScalerOf(context).scale(1)or the glyph is clipped. - Vertical centring in a fixed-height field is governed by the content
column's
mainAxisAlignment, not by an innerAlign. - State-dependent text colour must be resolved where the state is known. A
DefaultTextStyleinside a builder's capturedchildcannot see hover. ShadTextareahas its own theme; it previously readShadInputThemefor padding and alignment, which made every textarea-specific value dead.- A loop that only ever
awaits a synchronously-completed future never returns to the event loop. Dart drains the whole microtask queue before taking the next event, so such a loop freezes the app outright: no frames, no pointer input, and no response to a window resize (the resize path isscheduleForcedFrame, which still needs an event-loop turn). This is not hypothetical —ShadSelect's hover-to-scroll chevrons hit it. A secondanimateToon aScrollPositioncallsbeginActivity, which disposes the previous activity, andDrivenScrollActivity.disposecompletes its completer synchronously; two loops therefore cancel each other in an unbroken microtask chain, and because the offset only advances on a frame that never comes, neither exit condition is ever met. Any driver loop here needs a re-entrancy guard and a real yield (Future.delayed(Duration.zero)) per iteration.test/src/components/select_auto_scroll_test.dartlocks the guard. On web this reads as a browser bug, which is how it went unnoticed. - A fully transparent
ColoredBoxstill hit-tests as opaque (it extendsRenderProxyBoxWithHitTestBehaviorwithbehavior: opaque). The dialog route's full-screen barrier-blur layer uses one to give itsBackdropFilterextent, and without anIgnorePointerit silently ate every outside tap — barrier dismissal was dead for all dialogs and sheets and nobody noticed, because dialogs have close buttons.
- Most golden tests do not actually compare. They use
expect(finder, matchesGoldenFile(...)), and that matcher is asynchronous — an unawaitedexpectnever reports the mismatch. Onlysheet_test.dartusesawait expectLater(...), which is why those six are the only goldens that ever fail. Converting the rest is a known, unstarted job: it will surface every accumulated diff at once and require regenerating ~50 PNGs. - Goldens in the working tree were regenerated on macOS; CI runs on Linux. Treat a local regeneration as provisional.
- Prefer asserting resolved theme values over pixels. For anything with modes
(menu colour × finish × accent × brightness) walk the whole matrix in one test
and assert luminance contrast — that found a real collision (light + inverted
- bold puts a near-black primary on a near-black surface) that reasoning about cases had missed twice.
pumpAndSettlehangs on anything withShadSkeletonorShadSpinner; pump fixed frames instead.- Popover contents are wrapped in
IgnorePointerduring the entrance animation — pump ~12 × 50 ms before tapping an option, and pick options near the top of the list, since ones below the scroll fold are found but not tappable. - Example tests set
GoogleFonts.config.allowRuntimeFetching = false; the resulting log lines are noise, not failures.
- Component themes are generated:
dart run build_runner build --delete-conflicting-outputsafter touching anything underlib/src/theme/**. The generated*.g.theme.dartfiles are committed. - i18n uses slang's legacy builder — regenerate with
dart run slang, notbuild_runner. Onlylib/src/i18n/en.i18n.yamlneeds editing; other locales inherit from English. - Nothing under
lib/may importmaterial.dart,cupertino.dartor the package's own barrel —lib/src/app.dartandlib/src/theme/theme_scope.dartare the only exemptions (Material/Cupertino interop), and.github/workflows/check-imports.yamlenforces it. This is whyShadSpinneris aCustomPainterrather than a wrappedCircularProgressIndicator. - Verify with:
flutter analyzeandflutter testat the root, then the same inexample/, thendart analyzeincli/. The example consumes the package through the public barrel, so it catches export breakage that the root analyzer does not. There is no separateplayground/app any more — it was a standalone Flutter web project superseded by the example app's ownPlaygroundScreenand removed with its Firebase hosting config. example/macos/is gitignored, so changes there (e.g. thecom.apple.security.network.cliententitlement thatgoogle_fontsneeds) will not commit.
ShadSidebarScaffoldowns the responsive behaviour;ShadSidebarcarries the configuration (side/variant/collapsible) and renders the panel. Descendants readShadSidebarScope.of(context)for the collapsed state — that is how menu buttons shrink to squares and sub-menus vanish in the icon rail.- Both collapse modes keep content laid out at the expanded width inside
an
OverflowBoxand let the animated container clip it — that is what CSSoverflow-hiddendoes, and it is why a header that is a plainRow(logo + gap + label) truncates instead of throwing a RenderFlex overflow. Offcanvas anchors the box to the inner edge so the panel slides off; the icon rail anchors it to the start. - Because the parent no longer narrows, the icon rail's square is the menu
button's own doing: it animates an explicit width to
collapsedWidth − group padding(48−16 = 32 =px(8)+icon(16)+px(8)) inside anAlign(start, heightFactor: 1). Every fixed-width part of its row — the icon-label gap, the trailing badge — animates to zero on the same timeline, because anything that disappears instantly (or stays fixed) makes the row's minimum width overflow mid-animation in one direction or the other. - The mobile sheet is imperative: the scaffold listens to the controller and
pushes/pops a
ShadSheetwithscrollable: false— the sheet's own scroll view hands the sidebar'sExpandedcolumn unbounded height, and the crash surfaces as a misleadingmouse_trackerassertion.openMobileandopenare independent, as in the reference. Growing past the breakpoint pops the sheet (post-frame, fromdidChangeDependencies), matching the reference unmounting its mobile branch. - The drawer route outlives the scaffold's State, and its builder re-runs
on every MediaQuery change — so
_showMobileSheetcaptures everything the builder needs (controller, sidebar, collapsed width) instead of readingthis.widget/this.context, which are gone after unmount. The route itself is captured viaModalRoute.ofin the builder: closing goes through it (popwhen current,removeRoutewhen covered — never a blindNavigator.pop), anddisposeremoves an orphaned drawer post-frame. ShadSidebarScaffold.borderRadiusclips the whole scaffold; it exists for embedding in rounded containers (the docs demos), where the sidebar's square surface would otherwise cover the host's corners.- Sidebar metrics vary per style through
sidebarItemHeight/Sm/LgandsidebarItemPaddingX/sidebarSubItemPaddingXtokens; radius reusesitemRadius, the group label reuses theoverlinerole, button text thebodyrole. The floating/inset surface radius is the popover radius capped at the 2xl step (_sidebarSurfaceRadius), which matches all styles within 2px without a dedicated token.
example/lib is a docs site: common/app_shell.dart (top nav, two sections)
→ screens/components_screen.dart (a ShadSidebarScaffold listing
docs/registry.dart) → docs/docs.dart (the reusable page widgets). The
other section is screens/playground_screen.dart, which is the section the
app opens on.
- The code shown is the code that runs. Each example is one standalone
widget file under
lib/docs/examples/<slug>/<id>.dart, bundled as an asset (one pubspec entry per directory — asset dirs are non-recursive) and loaded withrootBundlebyCodeBlock.asset. Adding an example means: widget file +ComponentExampleindocs/pages/<slug>.dart+ the pubspec entry if the directory is new. - Code blocks are always dark, in JetBrains Mono (vendored in
example/fonts/), highlighted bysyntax_highlight, whose grammar loads inmain()viaCodeHighlighter.ensureInitialized()beforerunApp. example/test_driver/app.dartwrapsmain()inenableFlutterDriverExtension(); run withflutter run -t test_driver/app.dartto drive the app from tooling (screenshots, taps). Driver commands hang untilset_frame_sync: falsebecause doc previews contain forever-animating spinners.
example/lib/common/theme_editor/ mirrors /create. Points that were decided
deliberately:
- The editor themes the whole app, not a preview pane. The configuration
lives in
themeConfigProvider(main.dart) andShadApp'stheme,darkThemeand text scale are all built from it, so the top nav, the playground and the docs browser restyle together. There is no preview-onlyShadThemeScopeany more. customizer_panel.dartis the panel;app_shell.dartdocks it beside the page (ThemeCustomizerPanel.width) and lets it cover the viewport below 700px, where a docked panel leaves nothing usable. It is toggled from the top nav, so it is reachable from both sections.- The page is always the first child of the shell's
StackandRow, and the panel is added after it. Building the page conditionally instead reshaped the element aboveComponentsScreenand threw itsStateaway, so opening the editor bounced you back to the first component — which reads as "the editor does nothing to this page". - The customizer panel is fixed dark chrome at an unscaled text size, not part of the theme being edited — a panel that follows the configuration becomes unreadable while editing a light, menu-inverted or 1.6×-scaled theme.
- Light/dark and text direction are the app-wide toolbar switches; the panel does not duplicate them.
- Anything the app adds on top of the generated theme has to be passed
into
ThemeEditorConfig.build()—customTextStylesis howmain.dartkeeps itsmyCustomStyleexample alive, since the editor owns the wholeShadTextTheme. - The shell reads its signals in the
SignalBuilderbody, not inside theLayoutBuilderbelow it: that callback runs during layout, outside the scope solidart tracks, so a read in there never subscribes and the panel never opens. - Menu colours are derived from the menu's own opaque surface, never from
the page's
--accent, which vanishes on an inverted or translucent menu. fl_chartandgoogle_fontsare example-only dependencies. The package itself has neither.