@c96/ui
v0.1.2
Published
Reusable design system extracted from Studio C96 — tokens, motion primitives, hooks and a generic WebGL liquid-text effect.
Readme
@c96/ui
An opinionated design system extracted from Studio C96 — design tokens, a full accessible component library built on Base UI, motion primitives, hooks, and a generic WebGL liquid-text effect. Built for React 19 / Next.js (app router), monochrome and type-led by default, and fully re-skinnable.
- Tokens, split for re-skinning — structural tokens (type scale, spacing, motion, z-index) are separate from brand tokens (colours), so a new project overrides one layer.
- Default typeface: Public Sans (SIL OFL — redistributable). Swap it by
overriding
--font-sans. - Component library on Base UI — form controls, overlays (dialog, menu,
popover, tooltip, toast…) and structure primitives, headless behavior +
accessibility from
@base-ui/react(a regular dependency — nothing extra to install), styled with the c96 tokens. - Motion primitives —
SplitText,MaskText,SplitLines,SplitParagraphs,MagneticButton,SmoothScroll,AppLink. - WebGL — a generic
LiquidText(any lines, any font) on an isolated subpath. - Accessible by default — keyboard nav, focus management and aria wiring
come from Base UI; everything resolves cleanly under
prefers-reduced-motion.
Install
npm install @c96/ui
# peers (install what you use):
npm install react react-dom motion lenis
# only if you use @c96/ui/webgl:
npm install three @react-three/fiber @react-three/dreiPre-built ESM with
"use client"directives. In Next.js add it totranspilePackagesso the directives are picked up:// next.config.ts export default { transpilePackages: ["@c96/ui"] };
Wiring
1. Import the stylesheet once (root layout)
import "@c96/ui/styles";This pulls in, in order: fonts → structural tokens → brand theme → reset →
utilities → component styles → lenis hooks → reduced-motion. The Public Sans
@font-face files ship inside the package and resolve automatically.
2. Root layout — smooth scroll + toasts
"use client";
import { SmoothScroll, Toast } from "@c96/ui";
function Toasts() {
const { toasts } = Toast.useToastManager();
return toasts.map((toast) => (
<Toast.Root key={toast.id} toast={toast}>
<Toast.Content>
<Toast.Title />
<Toast.Description />
</Toast.Content>
<Toast.Close aria-label="Close">×</Toast.Close>
</Toast.Root>
));
}
export default function Providers({ children }) {
return (
<Toast.Provider>
<SmoothScroll>{children}</SmoothScroll>
<Toast.Portal>
<Toast.Viewport>
<Toasts />
</Toast.Viewport>
</Toast.Portal>
</Toast.Provider>
);
}(Skip the Toast parts if the site doesn't push notifications — nothing else depends on them.)
3. Use the components
import { SplitText, MagneticButton, Field, Input } from "@c96/ui";
<h1><SplitText text="Studio grade." /></h1>
<MagneticButton onClick={...}>Get in touch</MagneticButton>
<Field.Root name="email">
<Field.Label>Email</Field.Label>
<Input type="email" required />
<Field.Error match="valueMissing">Required.</Field.Error>
</Field.Root>Entry points
| Import | Contents | Notes |
| --------------------- | ----------------------------------------------------- | ----- |
| @c96/ui | Components + hooks + cx + tokens/math | Client-tagged |
| @c96/ui/tokens | EASE, DUR, Z, math helpers | Pure, server-safe |
| @c96/ui/webgl | LiquidText, LiquidContainer | Needs three / r3f / drei |
| @c96/ui/styles | The full stylesheet | Import once |
| @c96/ui/styles/* | Individual CSS files (e.g. tokens.base.css) | For custom composition |
| @c96/ui/fonts/* | Raw font files | Copy to public/ for WebGL |
Components
Most components are compound: they mirror Base UI's part model 1:1 as a
namespace object (Dialog.Root, Dialog.Trigger, Dialog.Popup, …). Every
part accepts Base UI's full prop surface — including the render prop to swap
the underlying element (<Dialog.Trigger render={<Button>Open</Button>} />) —
with className narrowed to a plain string; state styling is handled in CSS
via Base UI's data attributes ([data-open], [data-checked],
[data-invalid], …). The docs/ app is a living reference with one page
per component.
import { Dialog, Button } from "@c96/ui";
<Dialog.Root>
<Dialog.Trigger render={<Button>Open</Button>} />
<Dialog.Portal>
<Dialog.Backdrop />
<Dialog.Popup>
<Dialog.Title>Title</Dialog.Title>
<Dialog.Close render={<Button>Close</Button>} />
</Dialog.Popup>
</Dialog.Portal>
</Dialog.Root>Actions — Button (variant?: "default" | "accent" | "danger",
size?: "default" | "small", render for link-buttons), Toggle +
ToggleGroup (exclusive by default, multiple for multi-select), Toolbar
(one tab stop, arrow-key nav).
Forms — Form (aggregates native validation, maps server errors onto
fields), Field (Root/Label/Control/Description/Error — automatic
label/aria/id wiring), Fieldset, Input (compact? for inline table-cell
edits), Textarea (styled native; compose with
<Field.Control render={<Textarea />} />), Select (full listbox with
typeahead) and NativeSelect (zero-JS <select>), Checkbox +
CheckboxGroup, Radio + RadioGroup, Switch, NumberField
(steppers, wheel/arrow stepping, scrub area), Slider (single or range).
Overlays — Dialog (focus trap, scroll lock), AlertDialog (no light
dismiss), Popover, Tooltip (wrap groups in Tooltip.Provider), Menu +
ContextMenu (typeahead, submenus, checkbox/radio items), PreviewCard,
Toast (mount Toast.Provider + Toast.Viewport once at the root — next to
SmoothScroll — then push from anywhere with Toast.useToastManager()).
Structure & feedback — Tabs (sliding indicator), Accordion,
Collapsible, ScrollArea, Separator, Avatar, Progress
(determinate/indeterminate), Meter.
Table — Table, TableHead, TableBody, TableRow, TableCell,
TableHeaderCell: a thin structural wrapper, not a data-grid. Nest plain
<thead>/<tbody>/<tr>/<th>/<td> inside; reach for
TableCell/TableHeaderCell only for the hint and
sortable/sorted/onSort behaviors.
Motion — SplitText (per-character reveal), MaskText (single-line
reveal), SplitLines (measures wrapped lines, staggers on scroll-in),
SplitParagraphs, MagneticButton (cursor-following, static on
touch/reduced-motion), SmoothScroll (Lenis wrapper), AppLink (safe
external defaults).
Overlay notes
- The reset sets
isolation: isolateon<body>(a Base UI requirement) so portaled popups stack correctly without z-index wars; overlays sit on--z-overlay. If you portal outside<body>, replicate the isolation. - Portals and theming: popups portal to
<body>, so a popup triggered from inside a[data-theme="dark"]section renders on the light root theme. Theme at the app root (<html data-theme="dark">) or pass acontainerto thePortalpart.
Breaking changes vs the pre-Base-UI 0.1.0 API
| Old | New |
| --- | --- |
| Label | removed — use Field.Label |
| Field({ label, hint, children }) | compound Field.Root/Label/Description/Error |
| Select (styled native) | Select is now the Base UI compound; native is NativeSelect |
| Input, Textarea, Button, Table* | same shape, upgraded internals |
| @c96/ui/styles/components/form.css | split into field/fieldset/form/input/… files |
Future candidates (exist in Base UI, not wrapped yet): Autocomplete, Combobox, Drawer, OTP Field, Menubar, NavigationMenu.
cx
The tiny class joiner used internally is exported:
cx("a", cond && "b") → "a b". Server-safe.
Hooks
useMediaQuery, useIsTouch, usePrefersReducedMotion, useIsClient — all
SSR-safe.
Tokens in JS
import { EASE, DUR, Z } from "@c96/ui/tokens";
// EASE.outExpo -> [0.16, 1, 0.3, 1] (matches --ease-out-expo)WebGL liquid text
"use client";
import { LiquidText } from "@c96/ui/webgl";
import { useIsClient } from "@c96/ui";
function Hero() {
const isClient = useIsClient(); // canvas is client-only
return (
<div style={{ height: "70svh" }}>
{isClient && <LiquidText lines={["HELLO", "WORLD."]} animate />}
</div>
);
}The font is fetched at runtime by troika, so it must be a URL your app serves.
Copy the bundled font into your public/ once:
cp node_modules/@c96/ui/fonts/PublicSans-Variable.ttf public/fonts/The default font prop is /fonts/PublicSans-Variable.ttf. Pass your own
(heavier display) font URL via font="...". Trail look is tunable via
radius, strength, dissipation, dispStrength.
Re-skinning
Structure lives in tokens.base.css; the brand lives in tokens.theme-c96.css.
To recolour without touching components, import the stylesheet then override the
brand custom properties:
/* your app */
@import "@c96/ui/styles";
:root {
--paper: #ffffff;
--ink: #111111;
--accent: #0044cc;
--accent-contrast: var(--paper); /* text on accent-filled surfaces */
--bg: var(--paper);
--fg: var(--ink);
--fg-muted: #777;
--rule: rgba(0, 0, 0, 0.12);
--success: #2e8b57;
--warning: #d99a2b;
--danger: #d13c2f;
--hover-tint: rgba(0, 0, 0, 0.04);
}A [data-theme="dark"] block ships with the C96 theme and flips --bg/--fg/--fg-muted/
--rule/--hover-tint. --success/--warning/--danger are semantic status colours
(used sparingly, same discipline as --accent) — override them alongside the rest of the
brand layer if your palette needs different ones.
Documentation site
docs/ is a Next.js app serving as the full documentation site: a landing
page, Getting started and Theming guides, one page per component (live demos,
highlighted copyable snippets, props tables, keyboard notes), a token viewer
with live values, and a global dark-theme switch.
cd docs && npm install && npm run devIn dev, the docs app resolves @c96/ui from src/ (turbopack alias), so
editing the library source hot-reloads instantly — no npm run build needed.
Production builds keep resolving dist/ for published-artifact parity.
The site is a static export: cd docs && npm run build produces a
self-contained docs/out/ ready to host anywhere (e.g. Cloudflare Pages).
Develop
npm run build # tsup -> dist/ (per-file ESM, preserves "use client") + d.ts + CSS
npm run typecheck # tsc --noEmit (strict)License
MIT for the code. Public Sans is licensed under the SIL Open Font License 1.1
(see fonts/OFL.txt).
