Skip to content

Commit 710bc10

Browse files
abernierclaude
andauthored
Take the Base UI build of the same shadcn style (#200)
* Take the Base UI build of the same shadcn style The registry ships every style twice, once per primitive library, and `components.json` names which one: `radix-maia` becomes `base-maia`, the 17 components are re-added from it, and `radix-ui` leaves for `@base-ui/react`. The look is the same pack of styles either way -- what changes is what is underneath the wrappers, and the handful of call sites that were talking to Radix through them. None of the 17 were modified locally (`add --diff` had only prettier's quarrel with the registry to report), so the swap is the registry's to make. Two of them are edited on purpose afterwards, below. The call sites, in the order the type checker found them: - `asChild` is Radix's. Base UI composes with `render`, handing a part the element it should become, so a trigger wrapping a `Button` becomes `render={<Button/>}` and a `Badge` that was a `<li>` becomes `render={<li/>}`. - `--radix-popover-content-available-height` is now `--available-height`, published on the positioner and inherited by the panel. - `<SelectContent position="popper">` is `alignItemWithTrigger={false}`. Same reasoning as before -- a trigger 26px from the top of the window cannot have the menu laid over it, and the pre-scroll that follows is what was mounting a scroll-up arrow over an option already in view. - `<SelectValue/>` prints the *value*, not the item's text, unless the root is handed the value/label map. Without `items` the trigger read `__all__`. Two edits to the registry files, both because the call sites need them: - `PopoverContent` forwards `collisionPadding` to the positioner. It picks four positioner props and this is a fifth; without it the prop lands on the popup as an unknown DOM attribute. - The info button is a tooltip trigger *and* a popover trigger, the nesting the composition guide prescribes. Two levels of `render` deep, the server keeps the outermost `data-slot` and the client the innermost, which is a hydration mismatch on that attribute alone; pinning it at each level to the one it describes settles it. The social bar's links keep `buttonVariants` on the `<a>` rather than becoming a `Button`: Base UI's is a real `<button>` and asks to stay one -- rendered as an anchor it wants `nativeButton={false}`, which trades the link role for `role="button"`. They are links. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Let Base UI's defaults stand The migration carried three props whose only job was to keep the Radix behaviour, and the registry file it edited to carry one of them. A different library is allowed to look a little different. `collisionPadding={12}` goes, and with it the fifth positioner prop `PopoverContent` had to forward: Base UI keeps 5px off the edge on its own, which on a 375px viewport puts the panel at 19px/5px rather than 12px/12px. `alignItemWithTrigger={false}` goes. The arrow-over-an-option-already-in-view it was avoiding is Radix's item-aligned mode, not this one -- laid over the trigger by Base UI the menu opens at `scrollTop: 0` with neither scroll button mounted, which is what the prop was there to obtain. The `__all__` sentinel goes, and the value/label map with it. Base UI has a cleared value of its own -- `null` -- so the option that drops the filter carries it, `SelectValue` falls back to its `placeholder`, and every other option is its own label. The state is still "" and the URL still empties. `data-slot` is pinned once rather than at each level: the outermost is the one the server keeps, so it alone decides what the client has to agree with. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Scroll the tag strip natively, with no bar to show Radix's `ScrollArea` defaults to `type="hover"`: the bar is not in the DOM until the pointer is over the area. Base UI has no such mode -- its scrollbar is mounted whenever the axis overflows, and the registry's classes give it no resting state -- so every tag strip long enough to scroll grew a permanent 2.5px bar across the bottom of the card. The strip never wanted a bar. The fade at its edges is what says there is more, which is why `scroll-fade-x` was aimed through the component at the element that actually scrolls. A plain `overflow-x-auto` div scrolls the same way, takes the fade directly, and has no bar to hide -- and its overflow is declared in CSS rather than written by the component, which is one less thing between the utility and the scroller it reads. `components/ui/scroll-area.tsx` has no callers left, so it goes; `shadcn add scroll-area` brings it back the day something wants it. Checked against the deployed `main` in the same browser, same viewport: same strips overflow by the same amounts, and both fade their ends once the page has settled. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Revert "Scroll the tag strip natively, with no bar to show" This reverts commit b3833c4. * Give the tag strip's scrollbar a resting state The bar the last commit removed the component to get rid of: Radix kept it out of the DOM until the pointer was over the area (`type="hover"`), Base UI mounts it as soon as the axis overflows and leaves it to CSS to say when it shows. The registry's classes give it no resting state, so every strip long enough to scroll wore one permanently. Which is a call-site fix, and `data-hovering` / `data-scrolling` are the API for it -- the scrollbar publishes both. `opacity-0` plus those two is Radix's hover mode, spelled out where it belongs. The fade was never the casualty here. With the vendored `ScrollArea` back, the three overflowing strips report the same `--scroll-fade-e` as the deployed `main`, to six decimal places. AGENTS.md says `components/ui/*` is never edited; it now says never deleted either, and spells out the two ways this migration found around the rule -- adding a prop the registry chose not to forward, and swapping a component out of a call site for hand-rolled markup. Its shadcn line also names the style the app is actually on now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * No scrollbar on the tag strip at all The last commit gave the bar a resting state. This one asks what it was for. The fade already says there is more, it says it without being pointed at, and that is the whole of what a finger gets -- a bar that appears on hover tells a touch screen nothing. On a 32px strip it was 10px of it, laid across the pills it described. So the strip keeps `ScrollArea` -- the viewport, the hidden native scrollbar, the wheel and swipe behaviour -- and simply doesn't mount a `ScrollBar`. Base UI mounts none of its own for an axis that doesn't overflow, so the strips now render with zero scrollbar elements. A/B'd on the same page, same protocol: with the bar and without it, the three overflowing strips report the same `--scroll-fade-e` to six decimal places. The bar was the only thing that changed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent e19f54c commit 710bc10

21 files changed

Lines changed: 786 additions & 1904 deletions

AGENTS.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,16 @@ Single-context: one `CONTEXT.md` + `docs/adr/` at the repo root (created lazily
1616

1717
### UI components
1818

19-
`apps/website` has Tailwind v4 + shadcn/ui set up (style `radix-maia`, base colour `neutral`, icons `lucide`). The vendored `shadcn` skill in `.claude/skills/shadcn/` is the source of truth for adding, updating and styling components — use it, and run the `shadcn` CLI rather than hand-writing registry files.
19+
`apps/website` has Tailwind v4 + shadcn/ui set up (style `base-maia` — the Base UI half of the registry, so the primitives are `@base-ui/react` and composition is the `render` prop, not `asChild` base colour `neutral`, icons `lucide`). The vendored `shadcn` skill in `.claude/skills/shadcn/` is the source of truth for adding, updating and styling components — use it, and run the `shadcn` CLI rather than hand-writing registry files.
2020

2121
**Components come from the CLI, never from a fetch.** `pnpm dlx shadcn@latest add <component>` ([docs](https://ui.shadcn.com/docs/cli)) — it resolves the registry for our `style`/`baseColor`/`iconLibrary`, pulls transitive components, and writes to the aliases in `components.json`. Never copy a component out of the docs site, `curl` a registry JSON, or hand-write a file into `components/ui/`: those bypass the preset and drift from what `shadcn@latest info` reports as installed. Reading the docs for a component's API is fine — installing from them is not.
2222

23-
**`components/ui/*` is vendored, not ours — never edit it.** Those files must stay what the registry emits (modulo `prettier`, which the repo runs over everything), so that `shadcn@latest add <component> --overwrite` is always a safe no-op and any of them can be swapped for the stock version tomorrow. If a component doesn't do what you need, the fix goes at the call site — `className` for layout, the built-in `variant`/`size` props for looks, composition (wrap it, or use `asChild`) for behaviour — or into the theme tokens in `app/globals.css`. Never into the component file. If you genuinely cannot express it from outside, write your own component next to it under `components/` rather than forking the vendored one.
23+
**`components/ui/*` is vendored, not ours — never edit it, and never delete one either.** Those files must stay what the registry emits (modulo `prettier`, which the repo runs over everything), so that `shadcn@latest add <component> --overwrite` is always a safe no-op and any of them can be swapped for the stock version tomorrow. If a component doesn't do what you need, the fix goes at the call site — `className` for layout, the built-in `variant`/`size` props for looks, composition (wrap it, or hand it a `render` element) for behaviour — or into the theme tokens in `app/globals.css`. Never into the component file. If you genuinely cannot express it from outside, write your own component next to it under `components/` rather than forking the vendored one.
24+
25+
Two corollaries, both learned the hard way while moving the registry from Radix to Base UI:
26+
27+
- **A prop the wrapper doesn't forward is not a reason to add it.** The registry picks a subset of the primitive's props on purpose. Drop the call that needed the extra prop and take the component's default instead — a positioner default that differs by 7px is not worth a file that `--overwrite` will silently revert.
28+
- **"Replace it with our own markup" is still touching it.** Swapping a vendored component out of a call site because its Base UI behaviour differs from the Radix one — and deleting the file once nothing imports it — leaves the same hole: the next `add` brings back a component nobody uses, and the behaviour that was tuned lives in hand-rolled markup instead. Reach for the component's own data attributes from the call site first (`data-hovering`, `data-scrolling`, `data-open`, …); they are the API for exactly this.
2429

2530
**The colour tokens are Material Design 3.** Every shadcn token in `app/globals.css` reads an `--md-sys-color-*` role, and [`material-theme-builder`](https://github.com/abernier/material-theme-builder) derives all of them from one source hex. Retuning the palette means changing `MCU_SOURCE` in `app/layout.tsx` — or the `scheme` / `contrast` / core-colour overrides in the `builder()` call next to it — never editing a token by hand. The site runs `scheme: "monochrome"`, which derives every role from the source's _tone_ alone and discards its hue: the chrome is greyscale on purpose, so that the only colour on a example page is the example. `MCU_SOURCE` still matters — swap the scheme and the mint comes straight back. Anything the m3 roles don't cover belongs in that call's `customColors`, which mints `--md-sys-color-<name>` and a matching `-on-` foreground.
2631

apps/website/app/examples/[examplename]/Info.tsx

Lines changed: 41 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -60,40 +60,50 @@ export function Info({ example }: { example: Example }) {
6060
beside it without the pill having to be rebuilt. */}
6161
<ButtonGroup className="rounded-4xl shadow-lg backdrop-blur-sm">
6262
<Tooltip>
63-
<TooltipTrigger asChild>
64-
<PopoverTrigger asChild>
65-
<Button
66-
variant="secondary"
67-
/* `icon-lg`, not `lg`: on the icon scale that is the size —
68-
plain `lg` would add the text sizes' horizontal padding and
69-
stretch the disc into a lozenge. */
70-
size="icon-lg"
71-
aria-label="Show example info"
72-
/* `secondary` reads the same open or shut; the panel is
73-
anchored to this button, so it should look pressed while the
74-
panel is up. */
75-
className="aria-expanded:bg-primary aria-expanded:text-primary-foreground"
76-
>
77-
<RxInfoCircled />
78-
</Button>
79-
</PopoverTrigger>
80-
</TooltipTrigger>
63+
{/* Base UI composes through `render` rather than Radix's `asChild`:
64+
each trigger is handed the element it should become, so both sets
65+
of props end up on the one button.
66+
67+
Each wrapper stamps its own `data-slot`, and two levels of
68+
`render` deep the server keeps the outermost while the client
69+
keeps the innermost — a hydration mismatch on that attribute
70+
alone. The outermost is the one that wins on the server, so
71+
naming the element it ends up being settles it. */}
72+
<TooltipTrigger
73+
data-slot="button"
74+
render={
75+
<PopoverTrigger
76+
render={
77+
<Button
78+
variant="secondary"
79+
/* `icon-lg`, not `lg`: on the icon scale that is the size —
80+
plain `lg` would add the text sizes' horizontal padding
81+
and stretch the disc into a lozenge. */
82+
size="icon-lg"
83+
aria-label="Show example info"
84+
/* `secondary` reads the same open or shut; the panel is
85+
anchored to this button, so it should look pressed while
86+
the panel is up. */
87+
className="aria-expanded:bg-primary aria-expanded:text-primary-foreground"
88+
>
89+
<RxInfoCircled />
90+
</Button>
91+
}
92+
/>
93+
}
94+
/>
8195
<TooltipContent side="bottom">info</TooltipContent>
8296
</Tooltip>
8397
</ButtonGroup>
8498

8599
<PopoverContent
86100
align="end"
87101
sideOffset={8}
88-
/* 12px of clearance, the same margin the bar itself keeps — the panel
89-
is as wide as the viewport allows on a phone, so without it Radix
90-
would park it flush against the edge. */
91-
collisionPadding={12}
92-
/* Radix measures the room left under the trigger and publishes it as
93-
`--radix-popover-content-available-height`, which is what the old
94-
`max-height: min(100dvh - 4.5rem, 34rem)` was approximating by
95-
hand. */
96-
className="max-h-(--radix-popover-content-available-height) w-[min(24rem,calc(100dvw-1.5rem))] overflow-y-auto overscroll-contain rounded-xl border border-border backdrop-blur-sm duration-200"
102+
/* Base UI measures the room left under the trigger and publishes it on
103+
the positioner as `--available-height`, which the panel inherits.
104+
That is what the old `max-height: min(100dvh - 4.5rem, 34rem)` was
105+
approximating by hand. */
106+
className="max-h-(--available-height) w-[min(24rem,calc(100dvw-1.5rem))] overflow-y-auto overscroll-contain rounded-xl border border-border backdrop-blur-sm duration-200"
97107
>
98108
<PopoverHeader>
99109
<PopoverTitle>{example.title}</PopoverTitle>
@@ -124,8 +134,8 @@ export function Info({ example }: { example: Example }) {
124134
<Section title="Tags">
125135
<ul className="flex flex-wrap gap-1">
126136
{example.tags.map((tag) => (
127-
<Badge key={tag} variant="secondary" asChild>
128-
<li>{tag}</li>
137+
<Badge key={tag} variant="secondary" render={<li />}>
138+
{tag}
129139
</Badge>
130140
))}
131141
</ul>
@@ -136,8 +146,8 @@ export function Info({ example }: { example: Example }) {
136146
<Section title="Libraries">
137147
<ul className="flex flex-wrap gap-1">
138148
{libraryLabels.map((library) => (
139-
<Badge key={library} variant="secondary" asChild>
140-
<li>{library}</li>
149+
<Badge key={library} variant="secondary" render={<li />}>
150+
{library}
141151
</Badge>
142152
))}
143153
</ul>

apps/website/app/examples/[examplename]/Social.tsx

Lines changed: 31 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ import { GoCommandPalette } from "react-icons/go";
66
import { RxOpenInNewWindow } from "react-icons/rx";
77
import { SiCodesandbox, SiGithub, SiStackblitz } from "react-icons/si";
88

9-
import { Button } from "@/components/ui/button";
9+
import { cn } from "@/lib/utils";
10+
import { Button, buttonVariants } from "@/components/ui/button";
1011
import { ButtonGroup } from "@/components/ui/button-group";
1112
import {
1213
Tooltip,
@@ -92,36 +93,43 @@ export function Social({
9293
>
9394
{actions.map(({ label, icon, href }) => (
9495
<Tooltip key={label}>
95-
<TooltipTrigger asChild>
96-
{href ? (
97-
/* `icon-lg`, not `lg`: on the icon scale that is the size —
98-
plain `lg` would add the text sizes' horizontal padding and
99-
stretch each square into a lozenge. */
100-
<Button asChild variant="secondary" size="icon-lg">
96+
<TooltipTrigger
97+
render={
98+
href ? (
99+
/* The variants, not the component: Base UI's `Button` is a
100+
real <button> and asks to stay one — rendered as an <a> it
101+
wants `nativeButton={false}`, which trades the link role
102+
for `role="button"`. These are links. */
101103
<a
102104
target="_blank"
103105
rel="noopener noreferrer"
104106
href={href}
105107
aria-label={label}
106-
/* Prose links are underlined site-wide from `@layer base`;
107-
an icon-only button is not prose. */
108-
className="no-underline"
108+
className={cn(
109+
/* `icon-lg`, not `lg`: on the icon scale that is the
110+
size — plain `lg` would add the text sizes' horizontal
111+
padding and stretch each square into a lozenge. */
112+
buttonVariants({ variant: "secondary", size: "icon-lg" }),
113+
/* Prose links are underlined site-wide from
114+
`@layer base`; an icon-only button is not prose. */
115+
"no-underline",
116+
)}
109117
>
110118
{icon}
111119
</a>
112-
</Button>
113-
) : (
114-
<Button
115-
type="button"
116-
variant="secondary"
117-
size="icon-lg"
118-
onClick={handleClick}
119-
aria-label={label}
120-
>
121-
{icon}
122-
</Button>
123-
)}
124-
</TooltipTrigger>
120+
) : (
121+
<Button
122+
type="button"
123+
variant="secondary"
124+
size="icon-lg"
125+
onClick={handleClick}
126+
aria-label={label}
127+
>
128+
{icon}
129+
</Button>
130+
)
131+
}
132+
/>
125133
<TooltipContent side="bottom">{label}</TooltipContent>
126134
</Tooltip>
127135
))}

apps/website/app/layout.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ export default function RootLayout({
118118
inter.className,
119119
"flex h-dvh overflow-hidden",
120120
"[--main-p:1.5rem] [--sidebar-w:200px] sm:[--sidebar-w:260px]",
121-
/* Radix portals the mobile sidebar's overlay under <body>, outside
121+
/* Base UI portals the mobile sidebar's overlay under <body>, outside
122122
`Nav`, so this is the nearest call site that can reach it. Keep
123123
the dim layer, but remove backdrop filtering: re-blurring a live
124124
WebGL canvas throughout the sheet animation is prohibitively

apps/website/components.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"$schema": "https://ui.shadcn.com/schema.json",
3-
"style": "radix-maia",
3+
"style": "base-maia",
44
"rsc": true,
55
"tsx": true,
66
"tailwind": {

0 commit comments

Comments
 (0)