Zeabur's components and styles for React, Base UI and Tailwind CSS v4.
- Tokens: Zeabur's palettes (
zGray,zViolet,zOrangered, as RGB triplets and as Tailwind colours), its semantic tokens (text-main,text-secondary,text-tertiary,border-main,bg-surface-page,bg-surface-frame,*-v2-border,*-v2-separator,*-v2-surface-hover,*-v2-surface-active,success,error…), status colours (ok,progress,warn,fail,idle, each with-softand-strong), usage and chart colours, and shadcn/ui's theme variables mapped onto them (bg-background,text-muted-foreground,bg-primary…), light and dark. - Type roles:
type-title(24px, 600),type-name(20px, 500),type-heading(16px, 600),type-body(13px),type-meta(12px),type-label(11px uppercase section labels),type-micro,type-nano,type-kbd,type-figure,type-mono, on the--text-<role>scale. - Components: one module each, on Base UI, styled with
cvaandcn: ActionTile, Alert, Avatar, Badge, Banner, Brand (Logo,Mark), Button, Card (withCardRow,SectionLabel), Chart, Checkbox, Collapsible, Combobox, Container, CopyInput, Dialog (position="top"keeps a command palette still while its list changes), Drawer, Empty (aninlinesize for one line in a section's place), Field, Frame (AppFrame,FrameSidebar,FramePanel,PanelHeader), IconBox, Illustration, Input, Kbd (Kbd,KbdGroup,shortcutKeys), Label, Menu, Meter, PageColumn (the column every block of a page sits in, so their edges line up;widthcontent, wide or full,gapsm 12 to xl 40 between blocks; vertical space around it is margin), PageHeader (its actions wrap within the column on a narrow screen), PageTransition (usePageTransition,pageViewTransition), Popover, Presence (usePresence, an exit transition for an element Base UI does not own), Radio, SectionHeader (a section's title, count, one-line description and action), SegmentedBar, Select, Separator, Sidebar (with the drawer'sSidebarMenutree,SidebarSearch, theSidebarPickertrigger andSidebarCopyPill), Skeleton, Sonner (Toaster), Spinner (LoadingDots), Stat, SummaryCard (a status, its facts and actions), SwapText (a value that fades in when it changes), Switch, Table, Tabs, Term (a plain name with the technical name it stands for,TermDetail), Textarea, Toggle, ToggleGroup, Tooltip. Menus, popovers, tooltips and dialogs load their Base UI code after the first page (src/components/popup.tsx). - Utilities: Zeabur's button surfaces (
button-primary,button-secondary,button-destructivefor the final confirm,button-secondary-destructivefor a destructive action on each row of a list), the raised navigation row (nav-active,nav-accent-bar), the frosted surface (surface-glass), loading dots (dot-wave), motion (popup-motion,dialog-motion,collapse-motion,swap-inand the rest under Motion),bg-dots,bg-hatch,hairline-ring,scrollbar-none,brand-logoandbrand-mark. - Fonts: Geist and Geist Mono, self-hosted (SIL Open Font License).
Preview everything in Storybook (light and dark in its toolbar):
nix develop # bun and Node.js, from flake.nix
bun ci
bun run storybook # http://localhost:6006Every size, colour, shadow, motion and layer in a component is a token in src/tokens.css (light
and dark), and bun run lint enforces it. When a design needs something new, add a token (or a
variant) first.
| Family | Tokens and classes | Use for |
|---|---|---|
| Type scale | --text-<role> with --text-<role>--line-height (and --font-weight, --letter-spacing) for title 24/32 600, name 20/28 500, heading 16/24 600, body 13/20, meta 12/16, label 11/16 500 uppercase, micro 11/16, nano 10/14, kbd 10/15 500, figure 20/28 500, mono 12; written as type-<role> |
Every text size that is not Tailwind's text-xs/text-sm/text-base (controls and table text). body is 13px body text and navigation rows; nano a badge or a count; kbd a key cap. Write type-*, not text-<role>: cn takes an unknown text-<role> for a colour and drops it beside text-main. cn does not merge a type-* with text-xs either, so a component sets each size's text in its size variant. The older --type-<role> variables remain as aliases |
| Text | text-main, text-secondary, text-tertiary, text-inverse, text-on-primary, text-nav-current, text-info-strong, text-brand |
What is read first; descriptions and idle navigation; hints, placeholders, section labels; on an inverted surface; on the violet; the current navigation row's label; a violet note's words; violet marks and links |
| Surfaces | bg-surface-page, bg-card/bg-surface, bg-surface-frame, bg-chrome, bg-surface-raised, bg-segmented-track, bg-v2-surface-hover, bg-v2-surface-active, bg-muted, bg-sidebar-search, bg-table-head, bg-table-row-hover, bg-glass-hover, bg-glass-selected, bg-toast-action, bg-overlay, surface-glass |
The page; what sits on it; a Container's frame; the sidebar; the white pill raised over a track; the track; hover and selected rows; a quiet fill (the copy pill); the drawer's search row; a table's heading band and hovered row; rows of the frosted list; toast buttons; what dims the page under a dialog; tooltips and dropdowns |
| Borders | border-v2-border, border-main, border-input, border-segmented-border, border-border-strong, border-border-popover, border-rule |
Cards, menus, dividers; the firmer edge (sidebar, containers, tables); a control's edge (fields, checkboxes, radios: 3:1 against the page); a segmented track's edge; hover edges; what floats; lines between rows |
| Brand and actions | primary, primary-hover, on-primary, --primary-from/--primary-to (and -hover-), --secondary-*, --destructive-*, --button-edge, --button-outline; button-primary, button-secondary, button-destructive, button-secondary-destructive |
Violet for actions, checked controls, focus and the current place; the button surfaces (a page without React writes the utilities) |
| Danger and status | destructive, danger, danger-hover, error, success; ok, progress, warn, fail, idle, each with -soft and -strong; usage-normal, usage-warning, usage-severe, usage-reserved; chart-1 … chart-5 |
Orange-red for errors (danger is full strength in both themes, destructive lifts on dark); a status as a dot, a pill fill and its text; usage bars; chart series |
| Navigation | rail, nav-current, --nav-pill-*, nav-active, nav-active-sub, nav-accent-bar |
A folding menu's tree rail; the current row's pill (with side shadows at the top level, without under a parent) and the 3px violet bar of the current child |
| Tracks and pictures | track, hatch-line, avatar, --tile-<tone>-from/-to, logo-violet, logo-orangered, mark-orangered |
A bar's empty part, a skeleton, an unlit dot; an unbounded track's hatching; an avatar without a picture; IconBox and Empty's icon tile; the logo's own colours |
| Switch | switch-track, switch-track-border, switch-on-from/-to/-border, switch-thumb-from/-to/-border, shadow-switch-track, shadow-switch-thumb |
The tactile switch only |
| Radius | rounded-xs 2px, rounded/rounded-sm 4px, rounded-md 6px, rounded-lg 8px, rounded-xl 12px |
A chart swatch; checkboxes, chips, key caps; buttons, fields, menus; navigation rows, banners, containers; cards |
| Shadows | shadow-xs/sm (raised), shadow-md/lg (float), shadow-xl/2xl (dialog), shadow-segmented, shadow-tile, shadow-icon-box, drop-shadow-tile-icon, --shadow-glass |
What sits on the page, floats, or is modal; the chosen segmented pill; the icon tile's glow; its icon's haze; the frosted surface |
| Motion | ease-emphasized (cubic-bezier(0.22, 1, 0.36, 1), also ease-popup); duration-micro 80, -quick 150, -fast 250, -medium 350; blur-swap; transition-field, -input, -visibility; the motion utilities and animate-skeleton, animate-skeleton-now, animate-dot-wave |
Everything that moves, at one of the four durations. The Motion section says which pattern takes which |
| Layers | z-raised 10, z-popup 50, z-tooltip 60 |
Raised inside a control (a tab's label, the tree's bar); menus, popovers, selects, dialogs; tooltips over them |
| Sizes | h-control-xs 28, -sm 32, -md 36, -lg 40 (size-, min-w- too); w-sidebar 224; max-w-page-content 64rem and max-w-page-wide 76rem (PageColumn, its padding included); w-sheet; w-dialog; max-h-dialog; max-h-listbox; pl-tree-indent; p-illustration-inset; aspect-logo; grid-cols-icon, -action, -stats, -tree, grid-rows-header |
Control heights; the drawer; the phone's navigation sheet; a centred dialog; a combobox list; a tree child; layouts the spacing scale cannot express. cn does not know these names, so a component sets each in one variant only, never in a base that a variant or a className changes |
| Utilities | card-padding, toggle-gap, segmented-cells, mask-fade-down, scroll-fade-x, indicator-dashed, hairline-ring, bg-dots, bg-hatch, scrollbar-none, brand-logo, brand-mark, sidebar-icon-bar, dot-wave; the tabs-vertical: variant |
Treatments with more than one declaration or a value a component computes (--cell, --gap) |
The palettes (zGray-*, zViolet-*, zOrangered-*, as RGB triplets and Tailwind colours) and
Tailwind's white and black are what the tokens are made of. They stay available to apps, but
only src/tokens.css names them.
bun run lint (Oxlint, .oxlintrc.json) fails on any of these:
shadcn/no-restyle(@shadcn/lint): a className on a component may change its layout only (margin, position, flex and grid placement); its colour, spacing, shape and type are its variants' and sizes'. Contracts allowRadioGroupa gap andTableCella colour andtype-mono. A treatment a page needs becomes a variant here.shadcn/no-raw-colors: no Tailwind palette colour (bg-red-500) and no colour the theme does not declare.zeabur/no-palette-classes(lint/zeabur.js): no palette colour of Zeabur's either (bg-zGray-800,text-zViolet-700,text-white): name what it is for with a semantic token.zeabur/no-unguarded-motion: notransition-transform,transition-all,animate-spinor another class that moves or resizes withoutmotion-safe:(ormotion-reduce:); the motion utilities carry their own reduced form.shadcn/no-arbitrary-values: notext-[0.8125rem],z-[60],ease-[…],w-[min(…)]. Use a token, or add one.shadcn/no-inline-styles: nostylebut custom properties carrying a computed value (--value,--cell,--dot-delay) that a class reads; no<style>element (a strict Content Security Policy refuses it).shadcn/no-unknown-classes: no class Tailwind cannot generate (it reads the theme through.storybook/preview.css, set astailwind.cssincomponents.json).shadcn/require-static-classes: a component's className must be readable: string literals, conditions andcn()of them, or theclassNameprop passed on.no-restricted-imports:@base-ui/react/<part>, never the root barrel (an app would bundle every part); no Radix, Headless UI or Motion (the components are on Base UI, motion is CSS); no@/imports (apps compile these sources with their own alias).- Oxlint's correctness rules, with the React and jsx-a11y plugins, as errors (except
jsx-a11y/prefer-tag-over-role, which asks for<img>on a span that draws an inline SVG, andlabel-has-associated-controlonLabel, which gets its control from its caller); a disable comment that disables nothing fails too.
Apps run the same rules on their code (the Zeabur app's .oxlintrc.json uses this setup with
componentImports: ["^@zeabur-oss/design-system/components/"]). The package exports the zeabur/*
rules as @zeabur-oss/design-system/lint, for an app's jsPlugins.
Motion says where something came from or went. One curve, ease-emphasized, and four durations:
| Pattern | Class | Open, close |
|---|---|---|
| Menu, popover, select, combobox | popup-motion (fades, grows from 0.97 at its anchor) |
250, 150 |
| Tooltip, after a 300ms delay; the next is instant | tooltip-motion |
150, 80 |
| Dialog | dialog-motion and the backdrop's overlay-motion |
250, 150 |
| Sheet, drawer, the dock | sheet-motion-start, sheet-motion-end, sheet-motion-bottom |
350, 250 |
| Toast | Sonner, retimed in Toaster |
350, 150 |
| Collapsible, the tabs indicator, the sidebar | collapse-motion, slide |
250 both ways |
| A status or label that changes | SwapText (swap-in: rises 4px out of a 2px blur) |
150, on change only |
| A number that changes | SwapText variant="number" (reveal-in, fades) |
150, on change only |
| A switch's thumb, a chevron | motion-safe:transition-transform duration-quick or duration-fast |
150, 250 |
| Loading | Skeleton: shows after 300ms, so a quick load never flashes it; delay="none" when something has already waited |
|
| Another page (a service, a project) | page-transition on the main panel, the router's pageViewTransition (usePageTransition where the browser has no typed view transitions): the old page fades, the new one rises 4px |
150, 250 |
| A tab's panel | TabsContent (reveal-in, fades) |
150, on change only |
Only the main panel moves between pages: the sidebar, the header bar, a change of tab or filter in
the address, and the first page loaded stay still. Do not animate logs, terminals, editors, polled
data or charts, anything that changes while someone types, or focus. Animate
layout (height, width) only in collapse-motion, slide and the sidebar. No delay before a close
and no overshoot.
With prefers-reduced-motion: reduce, transitions keep opacity and colour (up to 150ms) and drop
translate, scale, rotation, blur and size; src/base.css ends every animation after one frame.
Each motion utility carries its reduced form; anything else that moves is written
motion-safe: (the lint checks). An element Base UI does not mount takes usePresence, which
sets the same data-starting-style / data-ending-style and keeps it mounted until its exit
ends.
Add it from npm, pinned to a version:
bun add --exact @zeabur-oss/design-systemThe package ships its sources (TSX and CSS) and has no build step, so the app compiles it. The app
needs React 19, Base UI, Tailwind CSS v4, class-variance-authority and cn (peer dependencies;
recharts for the chart and sonner for the toaster, only if it uses them), and in its Vite
config:
plugins: [
react({
// The React Compiler skips node_modules unless `sources` names the files.
compiler: { sources: [resolve(import.meta.dirname, "src") + "/", "/node_modules/@zeabur-oss/design-system/"] },
exclude: /\/node_modules\/(?!@zeabur-oss\/design-system\/)/,
}),
tailwindcss(),
// `~icons/ph/<name>`: Phosphor icons, as the components import them.
Icons({ scale: 1, compiler: "jsx", jsx: "react" }),
],
// The pre-bundler runs no plugins, so it could not resolve `~icons/…`. What the design system
// imports is not pre-bundled either then, and Base UI reaches CommonJS: list it in `include`.
optimizeDeps: {
exclude: ["@zeabur-oss/design-system"],
include: ["@base-ui/react/select", "@base-ui/react/menu", "cn", "class-variance-authority" /* … */],
},with "types": ["unplugin-icons/types/react"] in its tsconfig, and unplugin-icons,
@iconify-json/ph, @svgr/core and @svgr/plugin-jsx among its dev dependencies.
Import the styles right after Tailwind in the app's stylesheet (they also tell Tailwind to read the components' classes):
@import "tailwindcss";
@import "@zeabur-oss/design-system";and the components one by one:
import { Badge } from "@zeabur-oss/design-system/components/badge"
import { Button } from "@zeabur-oss/design-system/components/button"Wrap the app in TooltipProvider (components/tooltip). The theme is light by default and dark
under a .dark class on <html> (set it before the first paint); pass the app's theme to Toaster
(components/sonner). Toaster needs a Content Security Policy that allows inline styles (Sonner
adds a <style> element).
The components, their props and variants, the tokens and the utilities of @engawahq/design-system
are all here under the same names, so an app moves by changing its import prefix
(@engawahq/design-system → @zeabur-oss/design-system) and its dependency. What it looks like
changes to Zeabur's. Not carried over: Mosaic and Tile (Illustration keeps its states and
ignores seed), and Tabs' line variant and TabsMore.
A page that is not a React app (such as the sign-in pages of Zeabur Identity, askama templates
compiled with the Tailwind CSS v4 CLI) uses tokens.css: the tokens, type roles, utilities and
base styles, without the components or @font-face, and with no url() to resolve (the logo masks
are inlined).
@import "tailwindcss" source(none);
@source "../templates";
@import "@zeabur-oss/design-system/tokens.css";
/* Follow the operating system: `dark:` by media query, the tokens by color-scheme. */
@custom-variant dark (@media (prefers-color-scheme: dark));
:root {
color-scheme: light dark;
}
/* Geist, served from the app's own assets (copy vendor/geist/*.woff2 there). */
@font-face {
font-family: "Geist";
font-weight: 100 900;
font-display: swap;
src: url(/assets/Geist-Variable.woff2) format("woff2");
}To switch by a class instead, as the React app does, leave out the two rules after the import,
write @custom-variant dark (&:is(.dark *)); and put class="dark" on <html>.
The templates use the components' classes from @zeabur-oss/design-system/variants/<name>. These
modules hold the cva functions of the components and import no React, so a build script can
call them and write the classes into the templates:
import { buttonVariants } from "@zeabur-oss/design-system/variants/button"
buttonVariants({ variant: "primary", size: "lg" })Write brand-logo h-5 for the logo and type-label for a section label.
| Path | Contents |
|---|---|
index.css |
The whole stylesheet for a React app: shadcn/ui's base styles, fonts, then tokens.css; dark: by .dark |
tokens.css |
src/tokens.css, src/utilities.css and src/base.css, for any Tailwind CSS v4 build |
fonts.css |
@font-face for Geist and Geist Mono (vendor/geist) |
src/tokens.css |
Palettes, semantic and component tokens, status, the shadcn/ui mapping, type scale, motion, shadows, layers, named sizes and the Tailwind theme |
src/utilities.css |
Type roles, button surfaces, navigation rows, motion, patterns, brand masks |
src/base.css |
The page's ground and text, focus rings, selection, scrollbars, reduced motion |
src/components/ |
The components (@zeabur-oss/design-system/components/<name>) |
src/variants/ |
The components' classes as cva functions, without React (@zeabur-oss/design-system/variants/<name>) |
vendor/shadcn/shadcn.css |
shadcn/ui's base styles (keyframes, data-* variants) |
vendor/geist/ |
Geist and Geist Mono, their licence |
vendor/zeabur/ |
The Zeabur logo and mark (see "Trademarks") |
stories/ |
Storybook stories: each component, and the tokens (Foundations) |
Files in vendor/ are copied verbatim and change only as a whole:
vendor/geist: from thegeistnpm package, release invendor/geist/VENDORED.vendor/shadcn: shadcn/ui'sshadcn/tailwind.css, release invendor/shadcn/VENDORED.vendor/zeabur: the logo and mark from Zeabur's dashboard and zeabur.com;src/brand.test.tschecks that the components and the masks draw them unchanged.
The Zeabur name, logo and mark are trademarks of Zeabur Inc. They are not licensed under the
Apache License 2.0 that covers the rest of this repository: the files in vendor/zeabur/, the
Logo and Mark components and the brand-logo and brand-mark utilities are here so Zeabur's
own products can show them. Do not use them to name, brand or represent another product or
service, or in a way that suggests Zeabur endorses it. A fork for another product replaces them.
Apache-2.0 (see LICENSE), except the trademarks above. Contributions are accepted under the Developer Certificate of Origin; see CONTRIBUTING.md.
Third-party content in this repository:
- shadcn/ui components (
src/components) and base styles (vendor/shadcn/shadcn.css), MIT License. - The package's shape, the popup loader (
src/components/popup.tsx) and several components' structure come from engawa's design system (Apache-2.0). - Geist and Geist Mono by Vercel with basement.studio (SIL Open Font License 1.1,
vendor/geist/LICENSE.txt). - Phosphor icons (MIT License), compiled into the app by unplugin-icons from
@iconify-json/ph.