The ClaraLight design language for React. Base UI primitives, Tailwind v4 tokens, and code you own.
packages/claralight/styles/theme.css is
the design source of truth. Components consume its tokens; checks validate local
declarations, references and rendered behavior.
The npm package and copy-in registry share component source.
The built registry is committed under public/r/, so the CLI reads it straight
from GitHub. In an existing React / Tailwind v4 consumer, merge this into
components.json:
{
"registries": {
"@claralight": "https://raw.githubusercontent.com/ClaralightDesign/react/main/public/r/{name}.json"
}
}Pin a tag or commit instead of main to install a fixed version.
Use aliases.lib: "@/lib". Both TypeScript and the CSS bundler must resolve @
to src; for Vite configure resolve.alias, not only tsconfig paths. The
tailwind.css entry must point to the app's imported global stylesheet, which
already imports tailwindcss.
pnpm dlx shadcn@4.21.0 add @claralight/buttonThe component pulls in shared files and dependencies, and the CLI adds theme/base
CSS imports. A copied project carries only what it installed: the anchored
surface — anchored.tsx plus anchored.css, and tooltip.css on top of it —
arrives with Popover and Tooltip rather than with the base, so a build of buttons
and cards has none of it. Do not replace an existing utils.ts without reviewing the diff:
ClaraLight needs its token-aware class merger.
Component sources are inlined into the registry JSON at build time, so a change
ships only once pnpm registry:build has been re-run and the result pushed.
pnpm check:registry tests this setup with a real CLI install in a temporary app.
Create a local tarball (the prepack hook builds and typechecks it):
pnpm --filter @claralight-design/react pack --pack-destination /tmpIn a consumer project, install the generated tarball plus the declared peers:
pnpm add /tmp/claralight-design-react-0.0.3.tgz @base-ui/react react react-dom/* app.css */
@import "tailwindcss";
@import "@claralight-design/react/styles.css";Dark is the default; add .light to <html> for the warm scheme. .dark is
also accepted. Tokens can be scoped to an ancestor. Squircle reads the local
cascade and updates its SVG effects when theme classes, props or inline tokens
change, without remounting children.
registry.json shadcn registry: 1 base + 10 components
packages/claralight/ @claralight-design/react
styles/theme.css design values and spring parameters
styles/base.css the primitives: press, frost, focus, entrance
styles/anchored.css popover/tooltip only: the surface with a tail
styles/tooltip.css tooltip only: the shared-tooltip morph
styles/scroll-area.css scroll area only: edge masks and scrollbars
styles/progress.css progress only: the rail's segments, the ring's arcs
src/lib/utils.ts cn(), with the class-group fix described below
src/lib/squircle.tsx the smooth-corner primitive
src/lib/anchored.tsx the anchored-overlay primitive, with anchored.css
src/lib/morph.tsx the surface morph, with Dialog and Select
src/ui/*.tsx components, one file each
apps/docs/ the gallery — a real consumer of the built dist
scripts/ local token, browser and registry checks
The gallery resolves @claralight-design/react through package.json#exports to
dist/, exactly as a consumer does. That is deliberate: aliasing it to src/
would render from a tree nobody else loads, so a broken exports map or a
d.ts that disagrees with the runtime would stay invisible. pnpm dev runs
tsdown --watch alongside Vite. pnpm typecheck first builds the library, so it
does not depend on a previous developer's dist.
| Version | Why this one | |
|---|---|---|
| Node | 24.18 | tsdown requires ^22.18 || ^24.11 || >=26 |
| pnpm | 11.5 | workspace catalog: protocol, lockfile supply-chain gating |
| TypeScript | 7.0 | strict typechecking; tsdown warns that its API is experimental |
| Vite | 8.3 | Rolldown-powered |
| tsdown | 0.23 | Rolldown + rolldown-plugin-dts |
| Tailwind | 4.3 | CSS-first @theme |
| Base UI | 1.8 | the primitives |
| Biome | 2.5 | lint and format in one pass |
| Vitest | 5.0 | Node unit and SSR tests |
| Puppeteer Core | 25 | actual browser behavior against built output |
isolatedDeclarations is off. Public variant types rely on inference from
cva. Declaration generation uses TypeScript instead of introducing manually
maintained annotations solely for a faster build.
corner-shape is unused. See below.
Every corner in the language is a smooth corner, and the web has two unrelated curve families for that which do not coincide:
| Construction | Support | |
|---|---|---|
corner-shape: superellipse(2) |
true Lamé superellipse, exponent n = 4 |
Chromium 139+ only |
| Figma corner smoothing | cubic Bézier shoulders bracketing a circular arc, p = (1 + ξ)·R, residual arc 90·(1 − ξ)° |
anywhere with JS |
ClaraLight follows the design, the design is drawn in Figma, and Figma draws the
second one. @lisse/react emits the same path construction across engines;
that does not guarantee identical font rendering or compositing across browsers.
Chromium's native corner-shape is a different curve, so it is not used even
though it is free there.
--cl-corner-smoothing is Figma's ξ and defaults to 0.6, Figma's own
default. apps/docs has a Corner shape page that renders the ξ ladder
side by side, which is where the curve should be signed off.
All five are handled in src/lib/squircle.tsx and styles/base.css, each for a
reason that is invisible until it breaks.
1. The shape needs a wrapper. Lisse re-renders borders and shadows as SVG
overlays appended to a positioned element that tightly wraps the shape, and
clip-path clips the shape's own painted border and box shadow to nothing. So
Squircle owns the wrapper — which is also why every component takes
wrapperClassName: the wrapper, not the shape, is what a caller's grid or flex
lays out.
2. clip-path intersects border-radius. The token radius is an inline CSS
fallback for SSR/first paint and is cleared once the path lands. Avoid adding
rounded-* to the shaped element. Caller style is merged, not overwritten.
3. clip-path crops outline. Measured: a 2px ring at 2px offset goes from
436 lit pixels to 0. The focus ring therefore rides on the wrapper, driven by
:has(> [data-cl-squircle]:focus-visible), which keeps the browser's own
keyboard/pointer heuristic and the 2px offset the design asks for.
4. Transforms belong on the wrapper, not the shape. The overlays are siblings
of the shape, so moving the shape alone tears the 1px border away from the edge —
which is why .cl-press, .cl-enter-root and .cl-anchored-root sit on the
wrapper. Both entrance classes use the same :has() trick to lift Base UI's
data-starting-style / data-ending-style onto the wrapper, so a dialog's fill,
border and shadow scale as one object.
5. One element, one transition. .cl-press and .cl-squircle-root very
often land on the same element, and two transition declarations resolve by
source order — which silently replaced the press spring with an outline fade.
.cl-press owns the transition and lists outline-color alongside its own
properties.
Lisse sets data-slot="smooth-corners" on every element it manages, and it
overwrites whatever was there. So ClaraLight's own markers are data-cl-slot,
matching the --cl-* token namespace. Using data-slot would have silently
erased the hook that every component test and consumer selector depends on.
A popover's arrow is not a second element. It is spliced into the surface's own smooth-corner path, which is forced by the design language rather than chosen for elegance:
- the frost fill is translucent, so two overlapping elements composite their alpha into a visible dark seam;
- the 1px outline would run straight across the tail's mouth.
Neither is a motion artifact; both are visible at rest. So src/lib/anchored.tsx
owns the shape instead of Lisse: the body still comes from @lisse/core's
generatePath, because that construction is the design system's corner geometry
and must not be forked, and the tail is spliced into it. Lisse's edges are
discrete absolute L commands, so the splice is an edit rather than a boolean
union. Where the anchored edge has no straight run left — its two corners meet in
the middle of it — no splice can hold a 24px base, and the surface becomes a union
with the tail's base sunk under the body instead.
Three consequences are worth knowing:
The tail is measured, not positioned. Base UI's Arrow is rendered but never
painted. Floating UI writes the already-clamped arrow offset onto it and
AnchoredSurface reads that back to place the tail, so there is one source of
truth for where the anchor is — including after a flip, which moves the tail to
the opposite edge on its own. The read is offset geometry rather than rects,
because the first measurement happens on the frame the surface is still at scale
0, where every rect inside it has collapsed to a point.
The entrance grows from the tail, not from 95%. --cl-enter-scale is a nudge,
for a dialog that has no fixed point to grow from. An anchored overlay has one:
transform-origin is the tail's tip, and the surface unfolds from scale 0 on the
overlay spring. Opacity is untouched on the way in — that leg belongs to reduced
motion, which drops the growth and keeps the fade in its place.
The tail moves the padding. It lives inside the element's box, on the edge
opposite the resolved side, so .cl-anchored-arrow[data-side="…"] insets the
content by --cl-arrow-extent again. [data-side] is Base UI's and names the side
of the anchor, which is why the padding is keyed on the opposite edge.
A floating layer does not fade in and it does not pop. It is laid out at its final
size and carried there from the rectangle that opened it, by a projective
transform on the wrapper, so the fill, the 1px outline and the content cross the
movement as one object. src/lib/morph.tsx owns that geometry for every layer
that has an anchor: the dialog today, the select next.
Three things about it are not obvious from the code and are worth knowing before changing anything.
Four corners, four curves. The corners do not move in step. Each one travels a
straight line on a cubic Bézier chosen by how far it has to go — x1 = 0.35 − 0.20r, y1 = 0.85r, x2 = 0.45 − 0.20r, y2 = 1, with r the corner's share
of the longest journey. A corner that barely moves waits and snaps in behind one
that crosses the layer, so the quad is a trapezoid on the way across rather than a
scaled rectangle. Those numbers are the Flutter implementation's, kept verbatim so
the two are one animation; the matrix only keeps the four corners coherent. Every
curve is monotonic and ends at 1, so the layer is never upscaled — see the Safari
note below.
The opacity leg is part of the morph, not a transition. It is read from the
same progress the geometry is, easeOut(t / 0.35). Left to a CSS transition
instead, a dismissal empties the layer in its first half — ease-out reversed is
front-loaded — and the collapse back into the trigger happens where nobody can see
it. The reverse leg reads Base UI's own clock while it is at it: the exit
sentinel's animation is what Base UI unmounts on, so the morph tracks that
animation's progress rather than running a timer beside it, and the fade starts
slightly early because the last frame of a dismissal never reaches the screen —
animation events are dispatched before animation frame callbacks.
Resting is an identity. Both rectangles are expressed relative to the layer's
own resting box, so a finished morph is exactly the identity matrix and the inline
geometry can be dropped, leaving the CSS state describing the same pixels. That is
also why cl-morph is declared in the markup rather than added by the effect that
drives the morph: changing a computed scale is itself what starts the CSS
entrance's transition, so a component that reported itself as morphed one frame
late would animate the nudge it was replacing.
Six components are on Squircle and draw Figma smooth corners. The two anchored
overlays are on AnchoredSurface, which reserves the surface's outline for the
tail. The two progress indicators are on neither: below twice its own thickness a
smooth corner and a circular one coincide, so the rail is a plain capsule, and
the ring is a stroke with round caps.
| Component | Shape | Wrapper carries |
|---|---|---|
Button |
--radius-capsule clamped to height/2 |
press spring, focus ring |
Input |
--radius-control |
focus ring (on :focus, because a field takes focus on click) |
Card |
--radius-medium |
nothing — a surface does not take focus |
Dialog |
--radius-dialog |
fixed positioning, centring, the surface morph |
Popover |
--radius-panel |
--transform-origin, growth from the tail |
Select |
trigger --radius-control, popup --radius-medium |
press spring, focus ring, --transform-origin, entrance |
Tooltip |
--radius-medium |
as Popover, over an inert positioner |
ScrollArea |
--radius-medium, overridable |
nothing — the edges and bars live in CSS |
Progress |
--radius-capsule, no Squircle |
nothing — a rail this thin has no shoulder to draw |
CircularProgress |
a stroked arc, no corners at all | nothing — the ring is one SVG circle per arc |
Font assets are not bundled; see
styles/fonts/README.md.
Lisse documents that Safari caches the clip-path raster at the element's layout
size and upsamples it when an ancestor scales. ClaraLight's transforms are on
the wrapper, which is the shape's parent, so this applies: the press scales
1.0455x for 170ms, and an anchored overlay grows from 0 and overshoots to 1.011 on
the way. Nothing is ever upsampled by more than 4.6%, and the growth is a
downscale until its last frames. The surface morph is a downscale for its whole
entrance, because every corner curve is monotonic and ends on its target — which is
a property worth keeping if the corner easing is ever retuned.
The alternative — putting the transform on the shaped element — is worse on every browser, because the SVG border and shadow would then not scale with the fill.
src/lib/utils.ts configures createCn rather than re-exporting cn directly,
and this is not optional.
Tailwind's text-*, shadow-* and ring-* prefixes each cover two unrelated
utility groups. A merge library decides which group a class belongs to by
recognising the value, and it cannot recognise a token that only exists in
ClaraLight's @theme. The failure is silent:
cn("text-title text-foreground") -> "text-foreground"
The font size is gone, with no warning. Every ClaraLight type step is affected
because they are all custom names. Registering them as an explicit class group
fixes it, and src/lib/utils.spec.ts drives its assertions off theme.css, so
adding a token without registering it fails the suite rather than shipping.
unbundle: true is retained for the same kind of reason: a Button-only Vite
consumer measured about 10 KB less gzip than a single flattened library bundle.
Use Node >=24.11 and the pnpm version in package.json:
pnpm install --frozen-lockfile
pnpm checkcheck runs lint, local token validation, unit/SSR tests, fresh-build typechecks,
package exports validation, registry consumer installation and browser checks.
The registry smoke test installs dependencies in a temporary directory and needs
network access or a populated npm cache.
pnpm check:tokens # local declarations, references and token contracts
pnpm tokens # regenerate CSS springs from theme.css parameters
pnpm test # checker negative cases, class merging, refs and SSR
pnpm typecheck # builds the library before checking both packages
pnpm check:exports # explicit fresh build, then publint
pnpm check:registry # real CLI add, consumer typecheck and production build
pnpm check:gallery # builds, starts a local server and tests Chromium behaviorThe browser runner uses an installed Chromium-family browser. Set CL_BROWSER
to an executable when automatic detection cannot find yours. CL_GALLERY_URL
can point at an already-running preview instead of the managed local server.
Token expectations come from theme.css. Browser tests verify the compiled CSS
and actual behavior, including pointer press/release, reduced motion, dialog
focus, select keyboard interaction, the anchored tail and entrance, and dynamic
Squircle/ref integration.
When adding a component, add a unit test for pure contracts and a browser case for interaction or rendering. When adding a token, use it from component CSS and extend the class merger if its utility prefix is ambiguous.
Every token block is @theme static. Tailwind drops unused theme variables by
default, which is reasonable for an app and wrong for a design system: these names
are the public API, and consumers read them at runtime or reference them from
hand-written CSS. --ease-cl-drawer and --radius-sheet were both silently
missing from the output until this was set.