Installation

BoardUI works with any React 19 + Tailwind CSS v4 setup. Pick your framework below for a step-by-step guide, or follow the manual steps to copy the foundations in yourself.

Quick start

In any React + Tailwind v4 project, init installs the required packages, writes the theme, typography, and global styles plus the cx utility, and prints the one import line to add:

npx boardui@latest init

Then add any component — internal dependencies and npm packages resolve automatically:

npx boardui@latest add button select date-picker

Framework guides

Full setup from an empty folder. The steps only differ in how each framework wires Tailwind, the @/ import alias, and the global stylesheet.

  1. 1
    Create a project (skip if you have one).

    The create-next-app defaults — TypeScript, Tailwind CSS, and the @/* alias — are exactly what BoardUI expects.

    npx create-next-app@latest my-app# TypeScript: Yes · Tailwind CSS: Yes · import alias: @/* (the defaults)
  2. 2
    Run init in the project root.

    Installs react-aria-components, tailwind-merge, and @remixicon/react, and writes styles/ and utils/.

    npx boardui@latest init
  3. 3
    Import the stylesheet in your root layout.
    // app/layout.tsximport "@/styles/globals.css"; // replaces the default "./globals.css" import
  4. 4
    Add components.
    npx boardui@latest add button

Using something else? Any framework that runs React 19 with Tailwind CSS v4 works — wire up the @/* alias, run npx boardui init, and import the stylesheet. Or skip the CLI entirely with the manual steps below.

Manual installation

Prefer to wire things up yourself? Make sure you have a project with Tailwind CSS v4 set up, then follow these steps.

  1. 1
    Install the required packages
    npm install react-aria-components tailwind-merge @remixicon/react
  2. 2
    Createstyles/theme.csswith the design tokens.

    styles/theme.css

    // styles/theme.css/* --------------------------------------------------------------------------- * BoardUI theme tokens * * Layered as Tailwind v4 expects: * @theme { ... } primitives + ramp overrides → Tailwind utilities * :root { ... } semantic tokens (light mode) — reference primitives * .dark { ... } semantic overrides (dark mode) — landed when designed * @theme inline {..} re-export of semantic tokens so they become utilities * * See docs/FIGMA_WORKFLOW.md for how tokens get added (Figma is source of truth). * --------------------------------------------------------------------------- */ @theme { /* ----- Color primitives ---------------------------------------------------- * Almost the entire palette matches Tailwind v4's OKLCH defaults. The four * overrides below are the only intentional deviations (see Figma → variables * → color/...). * --------------------------------------------------------------------------*/ --color-slate-200: #ebeff5; --color-neutral-100: #f7f7f7; --color-neutral-200: #ebebeb; --color-blue-400: #3392ff; /* ----- Typography ---------------------------------------------------------- * Font sizes, weights, line-heights, and tracking all match Tailwind v4 * defaults. The only override is the family: Inter, loaded via next/font * in app/layout.tsx, which exposes --font-inter to the document. * --------------------------------------------------------------------------*/ --font-sans: var(--font-inter), ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; /* Monospace ramp for code blocks. Loaded via next/font in app/layout.tsx * (--font-mono-source → JetBrains Mono). Figma's spec uses IBM Plex Mono; * JetBrains Mono is the closest already-bundled face. */ --font-mono: var(--font-mono-source), ui-monospace, "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace; /* ----- Radii --------------------------------------------------------------- * Extra step between lg (8) and xl (12). Used by medium-size buttons. * Figma name: radius/2lg. * --------------------------------------------------------------------------*/ --radius-2lg: 10px; /* ----- Shadows ------------------------------------------------------------- * 2xs, xs, 2xl and the inner shadows already match Tailwind v4. Sizes sm, md, * lg, xl follow the Figma 6%/10% recipe (slightly softer than Tailwind v4's * 10%/10% default). * --------------------------------------------------------------------------*/ --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.06), 0 1px 3px 0 rgb(0 0 0 / 0.10); --shadow-md: 0 2px 4px -1px rgb(0 0 0 / 0.06), 0 4px 6px -1px rgb(0 0 0 / 0.10); --shadow-lg: 0 4px 6px -2px rgb(0 0 0 / 0.05), 0 10px 15px -3px rgb(0 0 0 / 0.10); --shadow-xl: 0 10px 10px -5px rgb(0 0 0 / 0.04), 0 20px 25px -5px rgb(0 0 0 / 0.10); /* Figma effect style "Background/Sidebar Elevation" — copied 1:1: * DROP_SHADOW #00000005 offset(0,1) radius 0 spread 0 → 0 1px 0px 0 / 0.0196 * DROP_SHADOW #0000000F offset(0,1) radius 12 spread 0 → 0 1px 12px 0 / 0.0588 * DROP_SHADOW #00000052 offset(0,0) radius 1 spread 0 → 0 0 1px 0 / 0.3216 * (alpha = hex byte / 255: 05→0.0196, 0F→0.0588, 52→0.3216). */ --shadow-sidebar: 0 1px 0 0 rgb(0 0 0 / 0.0196), 0 1px 12px 0 rgb(0 0 0 / 0.0588), 0 0 1px 0 rgb(0 0 0 / 0.3216); /* Figma effect style "Sidebar selected" — copied 1:1: * DROP_SHADOW color/blue/500 offset(0,0) radius 0 spread 1 → 0 0 0 1px blue-500 * INNER_SHADOW #FFFFFF40 offset(0,1) radius 0 spread 0 → inset 0 1px 0 0 / 0.251 * (#FFFFFF40 alpha = 0x40/255 = 0.251). */ --shadow-nav-selected: 0 0 0 1px var(--color-blue-500), inset 0 1px 0 0 rgb(255 255 255 / 0.251); /* Checkbox checked/indeterminate surface (from the Figma Checkbox component): * inner top highlight + 1px inner blue edge over the primary gradient. */ --shadow-checkbox-selected: inset 0 2px 0 0 rgb(255 255 255 / 0.25), inset 0 0 0 1px var(--color-blue-500); /* 1px contact shadow used on white cards/dropdown triggers sitting on the * secondary background (Figma drop shadow 0 1 1 5%). */ --shadow-card: 0 1px 1px 0 rgb(0 0 0 / 0.05); /* Floating menu/dropdown panel (Figma → sidebar profile menu node 3823:3699): * DROP_SHADOW #0000000A offset(0,1) radius 1 → 0 1px 1px 0 / 0.04 * DROP_SHADOW #00000005 offset(0,4) radius 4 → 0 4px 4px 0 / 0.02 */ --shadow-dropdown: 0 1px 1px 0 rgb(0 0 0 / 0.04), 0 4px 4px 0 rgb(0 0 0 / 0.02); /* Inner shadows (Figma names inner-shadow/*; Tailwind utility prefix is * inset-shadow-*). */ --inset-shadow-2xs: inset 0 1px 0 0 rgb(0 0 0 / 0.05); --inset-shadow-xs: inset 0 1px 1px 0 rgb(0 0 0 / 0.05); --inset-shadow-sm: inset 0 2px 4px 0 rgb(0 0 0 / 0.05);} /* --------------------------------------------------------------------------- * Semantic tokens — light mode. * * All values reference primitives via var(--color-{family}-{shade}). Edit the * primitives in @theme above and these update automatically. * * Figma name on the left, var name on the right: * foreground/full → --color-foreground-full * foreground/icon/primary → --color-foreground-icon-primary * foreground/disabled → --color-foreground-disabled * foreground/disabled-danger → --color-foreground-disabled-danger * text/primary → --color-text-primary * text/tertiary → --color-text-tertiary * text/disabled-danger → --color-text-disabled-danger * background/primary/default → --color-background-primary-default * background/primary/hover → --color-background-primary-hover * background/primary/active → --color-background-primary-active * background/primary/disabled → --color-background-primary-disabled * background/secondary/default → --color-background-secondary-default * border/button/default → --color-border-button-default * border/button/hover → --color-border-button-hover * border/button/active → --color-border-button-active * * Button gradients (linear, 180°, light stop → dark stop) sourced from the * blue/red primitives that each Button/{type}/{state} style references in * Figma. Direction inferred — confirm visually against Figma. * --------------------------------------------------------------------------- */:root { /* Foreground (icons + on-fill content) */ --color-foreground-full: var(--color-white); --color-foreground-icon-primary: var(--color-neutral-950); --color-foreground-icon-secondary: var(--color-neutral-500); --color-foreground-icon-tertiary: var(--color-neutral-400); --color-foreground-icon-quaternary: var(--color-neutral-300); --color-foreground-icon-error: var(--color-red-600); --color-foreground-icon-disabled: var(--color-neutral-300); --color-foreground-disabled: var(--color-neutral-400); --color-foreground-disabled-danger: var(--color-red-300); /* Text */ --color-text-primary: var(--color-neutral-950); --color-text-secondary: var(--color-neutral-500); --color-text-tertiary: var(--color-neutral-400); --color-text-disabled: var(--color-neutral-300); --color-text-disabled-danger: var(--color-red-300); --color-text-error-primary: var(--color-red-500); /* Background */ --color-background-primary-default: var(--color-white); --color-background-primary-hover: var(--color-neutral-100); --color-background-primary-active: var(--color-neutral-200); --color-background-primary-disabled: var(--color-neutral-100); --color-background-secondary-default: var(--color-neutral-100); --color-background-secondary-hover: var(--color-neutral-200); --color-background-tertiary-default: var(--color-neutral-200); --color-background-tertiary-error: var(--color-red-100); /* Border */ --color-border-button-default: var(--color-neutral-200); --color-border-button-hover: var(--color-neutral-300); --color-border-button-active: var(--color-neutral-400); --color-border-button-white: var(--color-white); --color-border-checkbox-default: var(--color-neutral-300); --color-border-focus-ring: var(--color-blue-500); /* Placeholder colors that don't map cleanly to existing text tokens */ --color-text-error-placeholder: var(--color-red-400); /* Charts (graphs) — shared neutrals + a categorical accent ramp. * `track` is the background bar/ring behind a series; `cursor` the hover * outline / dashed reference line; `neutral` a data color for "other"-style * slices. Each numbered accent pairs a base fill with a one-tone-darker * `-active` used for hover emphasis (the 400 → 500 recipe every chart * follows). Numbered (not hue-named) so the palette can be retuned without * renaming, mirroring the chart-1…n convention common to chart systems. */ --color-chart-track: var(--color-neutral-200); --color-chart-cursor: var(--color-neutral-300); --color-chart-neutral: var(--color-neutral-300); --color-chart-1: var(--color-teal-400); --color-chart-1-active: var(--color-teal-500); --color-chart-2: var(--color-lime-400); --color-chart-2-active: var(--color-lime-500); --color-chart-3: var(--color-pink-400); --color-chart-3-active: var(--color-pink-500); --color-chart-4: var(--color-sky-400); --color-chart-4-active: var(--color-sky-500); --color-chart-5: var(--color-purple-400); --color-chart-5-active: var(--color-purple-500); --color-chart-6: var(--color-blue-400); --color-chart-6-active: var(--color-blue-500); --color-chart-7: var(--color-emerald-400); --color-chart-7-active: var(--color-emerald-500); --color-chart-8: var(--color-yellow-400); --color-chart-8-active: var(--color-yellow-500); /* Button gradients */ --gradient-button-primary-default: linear-gradient(180deg, var(--color-blue-500) 0%, var(--color-blue-600) 100%); --gradient-button-primary-hover: linear-gradient(180deg, var(--color-blue-400) 0%, var(--color-blue-500) 100%); --gradient-button-primary-active: linear-gradient(180deg, var(--color-blue-600) 0%, var(--color-blue-700) 100%); --gradient-button-primary-disabled: linear-gradient(180deg, var(--color-neutral-200) 0%, var(--color-neutral-300) 100%); --gradient-button-danger-default: linear-gradient(180deg, var(--color-red-500) 0%, var(--color-red-600) 100%); --gradient-button-danger-hover: linear-gradient(180deg, var(--color-red-400) 0%, var(--color-red-500) 100%); --gradient-button-danger-active: linear-gradient(180deg, var(--color-red-600) 0%, var(--color-red-700) 100%); --gradient-button-danger-disabled: linear-gradient(180deg, var(--color-red-100) 0%, var(--color-red-200) 100%);} .dark { /* Semantic dark-mode overrides — landed when Figma dark designs arrive. */} /* Re-export semantic tokens as Tailwind utilities. The `inline` form means * Tailwind reads the *current* computed value of the variable, so light↔dark * flips work without rebuild. */@theme inline { --color-foreground-full: var(--color-foreground-full); --color-foreground-icon-primary: var(--color-foreground-icon-primary); --color-foreground-icon-secondary: var(--color-foreground-icon-secondary); --color-foreground-icon-tertiary: var(--color-foreground-icon-tertiary); --color-foreground-icon-quaternary: var(--color-foreground-icon-quaternary); --color-foreground-icon-error: var(--color-foreground-icon-error); --color-foreground-icon-disabled: var(--color-foreground-icon-disabled); --color-foreground-disabled: var(--color-foreground-disabled); --color-foreground-disabled-danger: var(--color-foreground-disabled-danger); --color-text-primary: var(--color-text-primary); --color-text-secondary: var(--color-text-secondary); --color-text-tertiary: var(--color-text-tertiary); --color-text-disabled: var(--color-text-disabled); --color-text-disabled-danger: var(--color-text-disabled-danger); --color-text-error-primary: var(--color-text-error-primary); --color-background-primary-default: var(--color-background-primary-default); --color-background-primary-hover: var(--color-background-primary-hover); --color-background-primary-active: var(--color-background-primary-active); --color-background-primary-disabled: var(--color-background-primary-disabled); --color-background-secondary-default: var(--color-background-secondary-default); --color-background-secondary-hover: var(--color-background-secondary-hover); --color-background-tertiary-default: var(--color-background-tertiary-default); --color-background-tertiary-error: var(--color-background-tertiary-error); --color-border-button-default: var(--color-border-button-default); --color-border-button-hover: var(--color-border-button-hover); --color-border-button-active: var(--color-border-button-active); --color-border-button-white: var(--color-border-button-white); --color-border-checkbox-default: var(--color-border-checkbox-default); --color-border-focus-ring: var(--color-border-focus-ring); --color-text-error-placeholder: var(--color-text-error-placeholder); --color-chart-track: var(--color-chart-track); --color-chart-cursor: var(--color-chart-cursor); --color-chart-neutral: var(--color-chart-neutral); --color-chart-1: var(--color-chart-1); --color-chart-1-active: var(--color-chart-1-active); --color-chart-2: var(--color-chart-2); --color-chart-2-active: var(--color-chart-2-active); --color-chart-3: var(--color-chart-3); --color-chart-3-active: var(--color-chart-3-active); --color-chart-4: var(--color-chart-4); --color-chart-4-active: var(--color-chart-4-active); --color-chart-5: var(--color-chart-5); --color-chart-5-active: var(--color-chart-5-active); --color-chart-6: var(--color-chart-6); --color-chart-6-active: var(--color-chart-6-active); --color-chart-7: var(--color-chart-7); --color-chart-7-active: var(--color-chart-7-active); --color-chart-8: var(--color-chart-8); --color-chart-8-active: var(--color-chart-8-active);} /* --------------------------------------------------------------------------- * Component utilities — Buttons * * Each variant collapses the four interactive states (default / hover / active * / disabled) into a single class. Use them on the component: * * <button class="bg-button-primary">…</button> * <button class="bg-button-danger">…</button> * * Smooth default→hover transition trick: * CSS can't interpolate `background-image`, so swapping gradients on :hover * would snap. Instead we keep the *default* gradient on the element and * layer the *hover* gradient on a `::before` pseudo-element whose opacity * fades 0→1 over `--button-transition-ms`. Active/disabled gradients still * swap instantly (we hide the pseudo to reveal them). * * Stacking: `isolation: isolate` keeps the pseudo behind the button's text * and icons (z-index -1) while staying above the element's own background. * `border-radius: inherit` + `overflow: hidden` keeps it within the rounded * corners. * --------------------------------------------------------------------------- */@theme { --button-transition-ms: 150ms; --input-transition-ms: 150ms;} @utility bg-button-primary { position: relative; isolation: isolate; background-image: var(--gradient-button-primary-default); &::before { content: ""; position: absolute; inset: 0; z-index: -1; pointer-events: none; border-radius: inherit; background-image: var(--gradient-button-primary-hover); opacity: 0; transition: opacity var(--button-transition-ms) ease; } &:hover:not(:disabled):not([aria-disabled="true"])::before { opacity: 1; } &:active:not(:disabled):not([aria-disabled="true"]) { background-image: var(--gradient-button-primary-active); } &:active:not(:disabled):not([aria-disabled="true"])::before { opacity: 0; } &:disabled, &[aria-disabled="true"] { background-image: var(--gradient-button-primary-disabled); } &:disabled::before, &[aria-disabled="true"]::before { display: none; }} @utility bg-button-danger { position: relative; isolation: isolate; background-image: var(--gradient-button-danger-default); &::before { content: ""; position: absolute; inset: 0; z-index: -1; pointer-events: none; border-radius: inherit; background-image: var(--gradient-button-danger-hover); opacity: 0; transition: opacity var(--button-transition-ms) ease; } &:hover:not(:disabled):not([aria-disabled="true"])::before { opacity: 1; } &:active:not(:disabled):not([aria-disabled="true"]) { background-image: var(--gradient-button-danger-active); } &:active:not(:disabled):not([aria-disabled="true"])::before { opacity: 0; } &:disabled, &[aria-disabled="true"] { background-image: var(--gradient-button-danger-disabled); } &:disabled::before, &[aria-disabled="true"]::before { display: none; }}
  3. 3
    Createstyles/typography.csswith the type scale.

    styles/typography.css

    // styles/typography.css/* --------------------------------------------------------------------------- * BoardUI typography * * Type ramp from Figma → text styles. Each style registers a Tailwind v4 * text utility — `text-{name}` applies font-size + line-height + letter-spacing * + font-weight in one go. * * Family: Inter for the whole ramp. (The Figma export marks Large Title / * Regular as "Inter Display"; we map everything to Inter for consistency since * Inter ships a variable optical-size axis. Flag if a true Inter Display load * is needed.) * * Weight mapping (mirrors Figma variables — font/weight/*): * Regular = 400 * Medium = 500 * Semibold = 600 (Figma variable font/weight/semibold = 600) * Bold = 700 * * Naming: `<family>-<weight>` in lowercase kebab — e.g. `text-body-medium`, * `text-title-1-semibold`, `text-caption-2-bold`. * --------------------------------------------------------------------------- */ @theme { /* ===== Large Title — 64 / 80 ============================================= */ --text-large-title-regular: 4rem; --text-large-title-regular--line-height: 5rem; --text-large-title-regular--letter-spacing: 0; --text-large-title-regular--font-weight: 400; --text-large-title-medium: 4rem; --text-large-title-medium--line-height: 5rem; --text-large-title-medium--letter-spacing: 0; --text-large-title-medium--font-weight: 500; --text-large-title-semibold: 4rem; --text-large-title-semibold--line-height: 5rem; --text-large-title-semibold--letter-spacing: 0; --text-large-title-semibold--font-weight: 600; --text-large-title-bold: 4rem; --text-large-title-bold--line-height: 5rem; --text-large-title-bold--letter-spacing: 0; --text-large-title-bold--font-weight: 700; /* ===== Display 1 — 56 / 72 =============================================== */ --text-display-1-regular: 3.5rem; --text-display-1-regular--line-height: 4.5rem; --text-display-1-regular--letter-spacing: 0; --text-display-1-regular--font-weight: 400; --text-display-1-medium: 3.5rem; --text-display-1-medium--line-height: 4.5rem; --text-display-1-medium--letter-spacing: 0; --text-display-1-medium--font-weight: 500; --text-display-1-semibold: 3.5rem; --text-display-1-semibold--line-height: 4.5rem; --text-display-1-semibold--letter-spacing: 0; --text-display-1-semibold--font-weight: 600; --text-display-1-bold: 3.5rem; --text-display-1-bold--line-height: 4.5rem; --text-display-1-bold--letter-spacing: 0; --text-display-1-bold--font-weight: 700; /* ===== Display 2 — 48 / 64 =============================================== */ --text-display-2-regular: 3rem; --text-display-2-regular--line-height: 4rem; --text-display-2-regular--letter-spacing: 0; --text-display-2-regular--font-weight: 400; --text-display-2-medium: 3rem; --text-display-2-medium--line-height: 4rem; --text-display-2-medium--letter-spacing: 0; --text-display-2-medium--font-weight: 500; --text-display-2-semibold: 3rem; --text-display-2-semibold--line-height: 4rem; --text-display-2-semibold--letter-spacing: 0; --text-display-2-semibold--font-weight: 600; --text-display-2-bold: 3rem; --text-display-2-bold--line-height: 4rem; --text-display-2-bold--letter-spacing: 0; --text-display-2-bold--font-weight: 700; /* ===== Display 3 — 40 / 54 =============================================== */ --text-display-3-regular: 2.5rem; --text-display-3-regular--line-height: 3.375rem; --text-display-3-regular--letter-spacing: 0; --text-display-3-regular--font-weight: 400; --text-display-3-medium: 2.5rem; --text-display-3-medium--line-height: 3.375rem; --text-display-3-medium--letter-spacing: 0; --text-display-3-medium--font-weight: 500; --text-display-3-semibold: 2.5rem; --text-display-3-semibold--line-height: 3.375rem; --text-display-3-semibold--letter-spacing: 0; --text-display-3-semibold--font-weight: 600; --text-display-3-bold: 2.5rem; --text-display-3-bold--line-height: 3.375rem; --text-display-3-bold--letter-spacing: 0; --text-display-3-bold--font-weight: 700; /* ===== Display 4 — 32 / 44 =============================================== */ --text-display-4-regular: 2rem; --text-display-4-regular--line-height: 2.75rem; --text-display-4-regular--letter-spacing: 0; --text-display-4-regular--font-weight: 400; --text-display-4-medium: 2rem; --text-display-4-medium--line-height: 2.75rem; --text-display-4-medium--letter-spacing: 0; --text-display-4-medium--font-weight: 500; --text-display-4-semibold: 2rem; --text-display-4-semibold--line-height: 2.75rem; --text-display-4-semibold--letter-spacing: 0; --text-display-4-semibold--font-weight: 600; --text-display-4-bold: 2rem; --text-display-4-bold--line-height: 2.75rem; --text-display-4-bold--letter-spacing: 0; --text-display-4-bold--font-weight: 700; /* ===== Title 1 — 24 / 34 ================================================= */ --text-title-1-regular: 1.5rem; --text-title-1-regular--line-height: 2.125rem; --text-title-1-regular--letter-spacing: 0; --text-title-1-regular--font-weight: 400; --text-title-1-medium: 1.5rem; --text-title-1-medium--line-height: 2.125rem; --text-title-1-medium--letter-spacing: 0; --text-title-1-medium--font-weight: 500; --text-title-1-semibold: 1.5rem; --text-title-1-semibold--line-height: 2.125rem; --text-title-1-semibold--letter-spacing: 0; --text-title-1-semibold--font-weight: 600; --text-title-1-bold: 1.5rem; --text-title-1-bold--line-height: 2.125rem; --text-title-1-bold--letter-spacing: 0; --text-title-1-bold--font-weight: 700; /* ===== Title 2 — 20 / 26 ================================================= */ --text-title-2-regular: 1.25rem; --text-title-2-regular--line-height: 1.625rem; --text-title-2-regular--letter-spacing: 0; --text-title-2-regular--font-weight: 400; --text-title-2-medium: 1.25rem; --text-title-2-medium--line-height: 1.625rem; --text-title-2-medium--letter-spacing: 0; --text-title-2-medium--font-weight: 500; --text-title-2-semibold: 1.25rem; --text-title-2-semibold--line-height: 1.625rem; --text-title-2-semibold--letter-spacing: 0; --text-title-2-semibold--font-weight: 600; --text-title-2-bold: 1.25rem; --text-title-2-bold--line-height: 1.625rem; --text-title-2-bold--letter-spacing: 0; --text-title-2-bold--font-weight: 700; /* ===== Title 3 — 18 / 24-26 ============================================== */ --text-title-3-regular: 1.125rem; --text-title-3-regular--line-height: 1.5rem; /* 24 per Figma */ --text-title-3-regular--letter-spacing: 0; --text-title-3-regular--font-weight: 400; --text-title-3-medium: 1.125rem; --text-title-3-medium--line-height: 1.625rem; /* 26 per Figma */ --text-title-3-medium--letter-spacing: 0; --text-title-3-medium--font-weight: 500; --text-title-3-semibold: 1.125rem; --text-title-3-semibold--line-height: 1.625rem; --text-title-3-semibold--letter-spacing: 0; --text-title-3-semibold--font-weight: 600; --text-title-3-bold: 1.125rem; --text-title-3-bold--line-height: 1.625rem; --text-title-3-bold--letter-spacing: 0; --text-title-3-bold--font-weight: 700; /* ===== Headline — 16 / 22 ================================================ */ --text-headline-regular: 1rem; --text-headline-regular--line-height: 1.375rem; --text-headline-regular--letter-spacing: 0; --text-headline-regular--font-weight: 400; --text-headline-medium: 1rem; --text-headline-medium--line-height: 1.375rem; --text-headline-medium--letter-spacing: 0; --text-headline-medium--font-weight: 500; --text-headline-semibold: 1rem; --text-headline-semibold--line-height: 1.375rem; --text-headline-semibold--letter-spacing: 0; --text-headline-semibold--font-weight: 600; --text-headline-bold: 1rem; --text-headline-bold--line-height: 1.375rem; --text-headline-bold--letter-spacing: 0; --text-headline-bold--font-weight: 700; /* ===== Body — 14 / 20 ==================================================== */ --text-body-regular: 0.875rem; --text-body-regular--line-height: 1.25rem; --text-body-regular--letter-spacing: 0; --text-body-regular--font-weight: 400; --text-body-medium: 0.875rem; --text-body-medium--line-height: 1.25rem; --text-body-medium--letter-spacing: 0; --text-body-medium--font-weight: 500; --text-body-semibold: 0.875rem; --text-body-semibold--line-height: 1.25rem; --text-body-semibold--letter-spacing: 0; --text-body-semibold--font-weight: 600; --text-body-bold: 0.875rem; --text-body-bold--line-height: 1.25rem; --text-body-bold--letter-spacing: 0; --text-body-bold--font-weight: 700; /* ===== Body 2 — 13 / 18 ================================================== */ --text-body-2-regular: 0.8125rem; --text-body-2-regular--line-height: 1.125rem; --text-body-2-regular--letter-spacing: 0; --text-body-2-regular--font-weight: 400; --text-body-2-medium: 0.8125rem; --text-body-2-medium--line-height: 1.125rem; --text-body-2-medium--letter-spacing: 0; --text-body-2-medium--font-weight: 500; --text-body-2-semibold: 0.8125rem; --text-body-2-semibold--line-height: 1.125rem; --text-body-2-semibold--letter-spacing: 0; --text-body-2-semibold--font-weight: 600; --text-body-2-bold: 0.8125rem; --text-body-2-bold--line-height: 1.125rem; --text-body-2-bold--letter-spacing: 0; --text-body-2-bold--font-weight: 700; /* ===== Caption 1 — 12 / 16 (tracking 0.15) =============================== */ --text-caption-1-regular: 0.75rem; --text-caption-1-regular--line-height: 1rem; --text-caption-1-regular--letter-spacing: 0.15px; --text-caption-1-regular--font-weight: 400; --text-caption-1-medium: 0.75rem; --text-caption-1-medium--line-height: 1rem; --text-caption-1-medium--letter-spacing: 0.15px; --text-caption-1-medium--font-weight: 500; --text-caption-1-semibold: 0.75rem; --text-caption-1-semibold--line-height: 1rem; --text-caption-1-semibold--letter-spacing: 0.15px; --text-caption-1-semibold--font-weight: 600; --text-caption-1-bold: 0.75rem; --text-caption-1-bold--line-height: 1rem; --text-caption-1-bold--letter-spacing: 0.15px; --text-caption-1-bold--font-weight: 700; /* ===== Caption 2 — 11 / 15 (tracking 0.2) ================================ */ --text-caption-2-regular: 0.6875rem; --text-caption-2-regular--line-height: 0.9375rem; --text-caption-2-regular--letter-spacing: 0.2px; --text-caption-2-regular--font-weight: 400; --text-caption-2-medium: 0.6875rem; --text-caption-2-medium--line-height: 0.9375rem; --text-caption-2-medium--letter-spacing: 0.2px; --text-caption-2-medium--font-weight: 500; --text-caption-2-semibold: 0.6875rem; --text-caption-2-semibold--line-height: 0.9375rem; --text-caption-2-semibold--letter-spacing: 0.2px; --text-caption-2-semibold--font-weight: 600; --text-caption-2-bold: 0.6875rem; --text-caption-2-bold--line-height: 0.9375rem; --text-caption-2-bold--letter-spacing: 0.2px; --text-caption-2-bold--font-weight: 700;}
  4. 4
    Createstyles/globals.cssas your Tailwind entry point.

    styles/globals.css

    // styles/globals.css@import "tailwindcss";@import "./theme.css";@import "./typography.css"; /* --------------------------------------------------------------------------- * Dark mode strategy * * We use a class-based dark variant. Toggle dark mode by adding the `.dark` * class to <html> (a theme provider like `next-themes` can do this later). * * <html class="dark"> → every `dark:*` utility activates * * Components should NOT use `dark:` prefixes directly. Instead they reference * semantic color tokens (e.g. `bg-bg-primary`, `text-text-primary`) that flip * under `.dark` in styles/theme.css. * --------------------------------------------------------------------------- */@custom-variant dark (&:where(.dark, .dark *)); /* Reset + base */html,body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; font-variant-ligatures: contextual; font-kerning: normal;} /* Hide default expand arrow on Safari */details summary::-webkit-details-marker { display: none;} /* Hide default number-input spinners */input::-webkit-outer-spin-button,input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0;}input[type="number"] { -moz-appearance: textfield;} /* Hide default search clear button */input[type="search"]::-webkit-search-cancel-button { -webkit-appearance: none;} /* iOS Safari auto-zooms the page when a focused form field's font-size is * under 16px — our text-body-regular/medium tokens are 14px by design, so * bump just the font-size (not the whole token) on small screens instead of * changing the design system's type scale. */@media (max-width: 639px) { input, select, textarea { font-size: 16px; }} /* Soft fade applied to an animated number each time its target changes */@keyframes number-fade { from { opacity: 0.35; } to { opacity: 1; }}.animate-number-fade { animation: number-fade 220ms ease-out;} /* Rolling-counter label swap: the old value lifts 5px and fades out while the * new one rises in from 5px below and fades in. */@keyframes label-out { from { transform: translateY(0); opacity: 1; } to { transform: translateY(-10px); opacity: 0; }}@keyframes label-in { from { transform: translateY(10px); opacity: 0; } to { transform: translateY(0); opacity: 1; }}.animate-label-out { animation: label-out 220ms ease-out forwards;}.animate-label-in { animation: label-in 220ms ease-out;} /* Checkbox tick: draws the stroke on like it's being ticked by hand. * The path sets pathLength=1 so the dash math is resolution-independent. */@keyframes check-draw { from { stroke-dashoffset: 1; } to { stroke-dashoffset: 0; }}.animate-check-draw { stroke-dasharray: 1; stroke-dashoffset: 1; animation: check-draw 200ms cubic-bezier(0.65, 0, 0.35, 1) forwards;}@media (prefers-reduced-motion: reduce) { .animate-check-draw { animation: none; stroke-dashoffset: 0; }} /* --------------------------------------------------------------------------- * Table primitive (components/base/table) * * React Aria's collection components (Column/Row/Cell/…) can't be wrapped in * custom components without losing collection behaviour, so the primitive * re-exports them untouched and styling lives here, scoped to `.bui-table`. * React Aria renders real <table>/<thead>/<th>/<tr>/<td> elements. * --------------------------------------------------------------------------- */.bui-table { @apply w-full border-collapse text-left;}/* Header background + border-y live on the cells: with border-collapse the * browser doesn't paint backgrounds/borders set on the <thead> row group. * Backgrounds/borders are set with explicit CSS (theme vars) rather than * @apply, which drops border-width/bg on these row-group cells. */.bui-table th { @apply px-3 py-2.5 text-left align-middle text-body-medium text-text-tertiary whitespace-nowrap outline-none; background-color: var(--color-neutral-100); border-top: 1px solid var(--color-border-button-default); border-bottom: 1px solid var(--color-border-button-default); transition: padding 200ms ease, font-size 200ms ease;}.bui-table td { @apply px-3 py-2.5 align-middle text-body-medium text-neutral-800 outline-none; transition: padding 200ms ease, font-size 200ms ease;}.bui-table tbody tr { @apply transition-colors duration-150; border-bottom: 1px solid var(--color-neutral-200);}.bui-table tbody tr[data-focus-visible] { @apply ring-2 ring-inset ring-border-focus-ring;}.bui-table.bui-table-sm th,.bui-table.bui-table-sm td { @apply px-2.5 py-1.5 text-body-2-medium;}
  5. 5
    Add theutils/cx.tsclass-merge utility.

    utils/cx.ts

    // utils/cx.tsimport { extendTailwindMerge } from "tailwind-merge"; /** * Text-style classes from styles/typography.css. * * IMPORTANT: every text-* utility we define via @theme (e.g. `text-body-medium`, * `text-title-1-semibold`) must be listed here. Otherwise tailwind-merge — which * has no view of our Tailwind theme — treats them as text-color utilities and * silently drops them when they appear in the same className as a real color * (`text-foreground-full`, `text-text-primary`, etc). * * If you add or rename a text style in typography.css, mirror the change here. */const TEXT_FAMILIES = [ "large-title", "display-1", "display-2", "display-3", "display-4", "title-1", "title-2", "title-3", "headline", "body", "body-2", "caption-1", "caption-2",] as const; const TEXT_WEIGHTS = ["regular", "medium", "semibold", "bold"] as const; const TEXT_STYLE_SUFFIXES = TEXT_FAMILIES.flatMap((family) => TEXT_WEIGHTS.map((weight) => `${family}-${weight}`),); const twMerge = extendTailwindMerge({ extend: { classGroups: { "font-size": [{ text: TEXT_STYLE_SUFFIXES }], }, },}); /** * Merge Tailwind classes safely. Last-write-wins on conflicting utilities. */export const cx = twMerge; /** * Identity helper that gives the Tailwind IntelliSense extension a hook for * sorting classes inside style objects (the extension doesn't sort inside * plain object literals otherwise). */export function sortCx< T extends Record< string, string | number | Record<string, string | number | Record<string, string | number>> >,>(classes: T): T { return classes;}
  6. 6
    Import the stylesheet in your root layout.
    // app/layout.tsximport "@/styles/globals.css"; // replaces the default "./globals.css" import

Add a component

Components live under components/base/… and import via the @/ alias. Copy the code from any component page — or let the CLI do it — then import:

import { Button } from "@/components/base/buttons/button"; export default function Page() { return <Button>Get started</Button>;}

Browse everything with npx boardui list, or copy per-component install commands from the individual component pages.

Dark mode

Dark mode is class-based: add .dark to <html> — via a tiny script, next-themes, or a system-preference listener. Component source never uses dark: prefixes; the semantic tokens flip under .dark in styles/theme.css.

<html className="dark"> {/* every semantic token flips */}

Tech stack

BoardUI components are built with React 19, TypeScript 5, Tailwind CSS v4, and React Aria for accessibility. You own the code — no runtime package, no third-party maintainers to wait on. Chart cards additionally use Recharts, and the data table uses TanStack Table; the CLI installs those automatically when a component needs them.