Everything needed to use the system.
Install, tokens, principles and the component reference — one page, so ⌘F finds anything.
Overview
assetis/ui is a private design system for Next.js. Near-monochrome surfaces carry the structure, one accent hue carries meaning, and colour appears nowhere else except four status indicators.
It ships TypeScript source rather than a build. Next compiles it with your app via transpilePackages, which means no bundler config, no release pipeline, and readable stack traces.
The rest of this site is the system demonstrating itself: Foundations, Components, Patterns and the app shell.
Installation
The package lives at the repository root, so a plain git dependency works. Always pin a tag rather than a branch — that keeps updates deliberate.
npm i git+https://git.jbarszcz.pl/ASSETIS/ui.git#v0.1.0
# or over SSH
npm i git+ssh://[email protected]/ASSETIS/ui.git#v0.1.0// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
transpilePackages: ["@assetis/ui"],
};
export default nextConfig;// app/layout.tsx
import { AssetisFonts, ThemeProvider, ThemeScript } from "@assetis/ui";
import "@assetis/ui/styles.css";
export default function RootLayout({ children }) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<AssetisFonts />
{/* applies the stored theme before first paint */}
<ThemeScript />
</head>
<body>
<ThemeProvider defaultAccent="indigo">{children}</ThemeProvider>
</body>
</html>
);
}Requirements
- Next.js 14+ with the App Router, React 18+.
- In a TypeScript project,
@types/reactand@types/react-dom— the package ships.tsx, so yourtscchecks it too.
AssetisFonts loads Manrope, Roboto Mono and Material Symbols. To self-host instead, skip it and point --a-font-sans, --a-font-mono and --a-font-icon at whatever you load.Updating
Move the pinned tag, then update. Nothing changes until you decide it does.
# edit package.json → "@assetis/ui": "git+https://…/ui.git#v0.2.0"
npm update @assetis/uiSee Releasing for cutting the tag on the design system side.
Principles
Eight rules. Everything in the system follows from them.
- Neutrals are chroma 0
- Literally grey. Colour only appears in the accent and the four indicators, which is what makes a status readable at a glance.
- No 1px borders
- Buttons, inputs, badges and cards are shaped by a three-step neutral fill ladder. Hairlines survive only where two regions genuinely meet — table rows, dividers, the index grid.
- One accent, two numbers
- A hue and a chroma multiplier drive every accent token, focus ring, badge and gradient field.
- One icon set
- Material Symbols Outlined at weight 200, everywhere.
- Outbound links are marked
- Links that leave the site get a trailing arrow and safe rel defaults. Navigation links never do.
- Light and dark from one definition
- Tokens use CSS light-dark(), so a theme switch flips color-scheme instead of redeclaring the palette.
- Motion is opacity only
- Nothing slides, scales or nudges. Transform-based animation renders text soft in some browsers, so the system does without it.
- Responsive is not optional
- Overlays become sheets under 620px, the sidebar becomes a drawer under 900px, tables stack into cards under 640px — even where that bends a rule above.
Design tokens
Every value the system uses, declared once in src/styles/tokens.css. This table is generated from that file, so it cannot drift.
fonts
neutrals — chroma 0, no exceptions
component fills — these replace 1px borders on buttons, chips, inputs
accent — derived, never hand-picked
indicators — the only other place chroma is allowed
radii
elevation — quiet, never a substitute for hierarchy
motion
space
type scale — fluid, so mobile never has to be patched
chrome
Accent system
The whole system is derived from two custom properties. Change them and every accent token, focus ring, badge, chart bar and gradient field follows.
:root {
--a-accent-h: 265; /* oklch hue, 0..360 */
--a-accent-c: 1; /* chroma multiplier: 0 = grey, 1 = default, 1.3 = vivid */
}Three ways to set it
// 1. globally, in your own CSS
:root { --a-accent-h: 320; }
// 2. at runtime
import { setAccent } from "@assetis/ui";
setAccent("teal"); // a preset
setAccent({ h: 320, c: 1.1 }); // anything
// 3. scoped to one subtree
import { accentStyle } from "@assetis/ui";
<section style={accentStyle({ h: 150 })}>…</section>Presets
Available as strings to setAccent / ThemeProvider defaultAccent, and as data-accent="teal" on any element.
Try it live on the Foundations page — the hue and chroma sliders re-tone the whole site as you drag.
Theming
Three states — light, dark and auto — so the control is a dropdown, not a toggle.ThemeScript applies the stored choice before first paint.
<ThemeSelect /> // icon only, for nav chrome
<ThemeSelect showLabel /> // icon + current mode name
// read or set it anywhere
const { mode, resolved, setMode, accent, setAccent } = useTheme();
// "system" "dark" fn "indigo" fn| Prop | Type | Default | Description |
|---|---|---|---|
defaultMode | "light" | "dark" | "system" | "system" | Used until a stored preference is found. |
defaultAccent | Accent | "indigo" | Preset name or { h, c }. |
storageKey | string | null | "assetis-ui" | localStorage prefix. null disables persistence. |
ThemeScript and ThemeProvider must use the same storageKey, or the pre-paint script and the app will disagree.Icons
Material Symbols Outlined at weight 200. The weight is a token, so it can be changed once for the whole system.
<Icon name="search" />
<Icon name="database" size={24} />
<Icon name="check_circle" filled />| Prop | Type | Default | Description |
|---|---|---|---|
name | string | — | Material Symbols ligature, e.g. arrow_outward. |
size | number | 20 | Rendered size in px; also picks the optical size axis. |
weight | 100–700 | 200 | Overrides --a-icon-weight for one icon. |
filled | boolean | false | Switches the FILL axis on. |
grade | number | 0 | Emphasis axis, −25 to 200. |
Browse names at Google Fonts icons.
Links
Link detects outbound hrefs, appends an arrow and sets rel="noopener noreferrer". Navigation components never do this — a nav item is not an outbound link.
<Link href="/foundations">Internal — no arrow</Link>
<Link href="https://example.com">Outbound — gets an arrow</Link>
<Link href="/docs" as={NextLink}>Client-side routed</Link>
<Link href="https://example.com" arrow={false}>Suppress the arrow</Link>Raw <a> tags inside .aui-prose get the same treatment from CSS, so rendered markdown behaves without wrapping every link.
App shell
A sidebar that collapses to an icon rail or hides entirely, plus a top bar with no border and no fill — a gradient veil and a masked blur, so page content dissolves as it scrolls underneath. Below 900px the sidebar becomes an overlay drawer and the toggle opens it.
<AppShell defaultSidebar="expanded" collapseTo="rail">
<AppSidebar
linkAs={NextLink}
brand={<><Mark /> <span>Acme Research</span></>}
groups={[
{ items: [{ label: "Overview", icon: "dashboard", href: "/app", active: true }] },
{ title: "Workspace", items: [{ label: "Settings", icon: "settings", href: "/app/settings" }] },
]}
footer={<SidebarUser name="Ada Karlsen" detail="Owner" />}
/>
<AppMain>
<AppTopBar actions={<ThemeSelect />}>
<Breadcrumbs items={[{ label: "Acme", href: "/app" }, { label: "Overview" }]} />
</AppTopBar>
<AppContent wide>{children}</AppContent>
</AppMain>
</AppShell>| Prop | Type | Default | Description |
|---|---|---|---|
defaultSidebar | "expanded" | "rail" | "hidden" | "expanded" | Initial desktop state. |
collapseTo | "rail" | "hidden" | "rail" | What the toggle collapses to on desktop. |
storageKey | string | null | "assetis-ui-sidebar" | Persists the desktop state. |
useAppShell() exposes sidebar, setSidebar, toggleSidebar, mobileNavOpen and isMobile for custom chrome.
Component index
Eighty-odd exports. Every one is demonstrated on the Components page.
Tiles
The editorial unit: a media block on top, then category, title, excerpt and byline. The media carries the colour; everything under it stays neutral, so a grid of tiles reads as a list rather than a carnival.
<TileGrid min={260}>
<Tile
as={NextLink}
href="/articles/latency"
field="dusk" // or media={<img … />}
featured // media beside the text from 760px up
flag={<Badge tone="solid">Featured</Badge>}
eyebrow={<>Engineering <span>·</span> 6 min</>}
title="How we cut p95 latency by 38%"
excerpt="Continuous batching, speculative decoding and region-aware routing."
meta={<><Avatar name="Rui Mendes" size={22} />Rui Mendes · 14 Aug 2026</>}
/>
</TileGrid>| Prop | Type | Default | Description |
|---|---|---|---|
field | GradientField | — | accent · dusk · tide · ember · spectrum. Fills the media block. |
media | ReactNode | — | Your own media instead of a gradient. |
featured | boolean | false | Spans the grid; media beside text from 760px. |
compact | boolean | false | Square thumb, for dense rails. |
flag | ReactNode | — | Pinned to the top-left of the media. |
aspect | string | "16 / 10" | CSS aspect-ratio for the media block. |
See them in use under Patterns → Article selection.
Overlays & toasts
Modal, Drawer, Menu, Tooltip and CommandPalette all render through a portal, lock body scroll, close on Escape and on a click outside, and trap focus while open. Below 620px they become bottom sheets.
<Modal open={open} onClose={() => setOpen(false)} title="Delete project?"
footer={<><Button variant="ghost">Cancel</Button><Button variant="danger">Delete</Button></>}>
…
</Modal>
<Drawer open={open} onClose={close} side="right" title="Request detail">…</Drawer>
<Menu trigger={<IconButton icon="more_horiz" label="More" />} align="end" items={[
{ type: "label", label: "This project" },
{ label: "Duplicate", icon: "content_copy", shortcut: "⌘D", onSelect: dup },
{ type: "separator" },
{ label: "Delete", icon: "delete", danger: true, onSelect: del },
]} />Toasts
// once, near the root
<ToastProvider>{children}</ToastProvider>
// anywhere below it
const { toast } = useToast();
toast({ title: "Deploy queued", description: "eu-west-2", tone: "ok" });
toast({ title: "Failed", tone: "err", action: { label: "Retry", onClick: retry } });Command palette
const [open, setOpen] = useCommandPalette(); // binds ⌘K / Ctrl-K
<CommandPalette open={open} onOpenChange={setOpen} commands={[
{ id: "1", group: "Navigate", label: "Go to billing", icon: "receipt_long",
onSelect: () => router.push("/app/billing") },
]} />Data tables
DataTable handles sorting, selection and row clicks. Below 640px each row becomes a labelled card — readability beats table purity.
<DataTable
rows={rows}
rowKey={(r) => r.id}
selected={selected}
onSelectedChange={setSelected}
onRowClick={setDetail}
minWidth={720}
empty={<EmptyState icon="search_off" title="No requests match" />}
columns={[
{ key: "route", header: "Route", sortValue: (r) => r.route },
{ key: "status", header: "Status", sortValue: (r) => r.status,
cell: (r) => <Badge dot tone={r.status >= 400 ? "err" : "ok"}>{r.status}</Badge> },
{ key: "ms", header: "Duration", numeric: true, sortValue: (r) => r.ms },
]}
footer={<><span>{selected.length} selected</span><Pagination … /></>}
/>| Prop | Type | Default | Description |
|---|---|---|---|
columns | Column<T>[] | — | key, header, cell, sortValue, numeric, align, width. |
rowKey | (row, i) => string | — | Stable identity for selection and React keys. |
selected / onSelectedChange | string[] / fn | — | Omit both to hide the checkbox column. |
stackOnMobile | boolean | true | Card-per-row below 640px. |
minWidth | number | 640 | Width before horizontal scrolling kicks in. |
Hooks
| Prop | Type | Default | Description |
|---|---|---|---|
useTheme() | ThemeContext | — | mode, resolved, setMode, toggle, accent, setAccent. |
useToast() | { toast, dismiss } | — | Must be under a ToastProvider. |
useAppShell() | ShellContext | — | Sidebar state and the mobile drawer. |
useCommandPalette() | [open, setOpen] | — | Binds ⌘K / Ctrl-K. |
useIsMobile() | boolean | — | True at or below 900px — the app-shell breakpoint. |
useMediaQuery(q) | boolean | — | SSR-safe media query subscription. |
useScrolled(px) | boolean | — | True once the page scrolls past px. |
useOnKey(key, fn) | void | — | Document-level key handler. |
useClickOutside(fn) | ref | — | Attach the ref to the element that should stay open. |
useScrollLock(active) | void | — | Reference-counted body scroll lock. |
useFocusTrap(active) | ref | — | Contains Tab within the element. |
useControllable(v, d, cb) | [value, set] | — | Controlled or uncontrolled, one hook. |
Responsive behaviour
The system has three breakpoints that matter. Everything else is fluid — clamp() handles type, gutters and section rhythm.
| Prop | Type | Default | Description |
|---|---|---|---|
≤ 620px | overlays | — | Modals, drawers and menus become bottom sheets. Modal footers stretch their buttons. |
≤ 640px | tables | — | DataTable rows become labelled cards. |
≤ 900px | chrome | — | The app sidebar becomes an overlay drawer; the marketing pill collapses its links into a sheet. |
≥ 760px | tiles | — | Featured tiles put their media beside the text. |
- Inputs render at 16px on phones so iOS does not zoom on focus.
- Touch targets: buttons gain an invisible 44px hit area under
(pointer: coarse). - Breadcrumbs shed segments as space runs out, keeping only the current page.
- Tooltips never appear on touch devices.
- Safe-area insets are respected on the pill, the toaster and bottom sheets.
Recipes
A marketing page
<MarketingNav linkAs={NextLink} brand={…} links={…} />
<div className="aui-marketing">{children}</div>
<MarketingFooter columns={…} />Scoping an accent to one section
import { accentStyle } from "@assetis/ui";
<Section style={accentStyle({ h: 150, c: 0.9 })}>
{/* buttons, rings and gradients in here are moss */}
</Section>A gradient field as decoration
<GradientPanel field="spectrum" radius="var(--a-r-2xl)" style={{ minHeight: 320 }}>
<Eyebrow>Colour is a tool</Eyebrow>
<h2>Fields belong on marketing surfaces.</h2>
</GradientPanel>Fields: accent, dusk, tide, ember, spectrum, plus the subtle wash and mist. All of them follow the accent hue.
Overriding a token for one app
@import "@assetis/ui/styles.css";
:root {
--a-accent-h: 190;
--a-r-lg: 10px; /* squarer cards */
--a-font-sans: "Inter", system-ui, sans-serif;
}Releasing
npm install # once — links the showcase to the package
npm run showcase # http://localhost:3000
npm run typecheck # checks the package source
npm run docs:tokens # regenerate the token tables on this page
# (run from showcase/ after editing tokens.css)
npm version minor # bumps package.json and tags the commit
git push --follow-tagsConsumers bump their pinned tag when they want the change. npm publish is blocked by a prepublishOnly guard, so the package can only ever be installed from git.
Repository layout
ui/
├── src/ the package — components, tokens, styles
│ ├── components/ one file per component group
│ ├── lib/ cx, accent helpers, hooks
│ └── styles/ tokens.css + six layers
├── showcase/ this site; consumes the package via file:..
└── package.json the repo root IS the packageChangelog
- Initial system: tokens, 80+ components, light/dark/auto.
- Floating pill marketing nav; app shell with a borderless fading top bar.
- Accent derived from a hue and a chroma multiplier.
- Material Symbols at weight 200 throughout.
- Editorial tiles and five hue-linked gradient fields.
- Motion reduced to opacity only.