A Vibra dashboard is meant to read like a clean instrument panel: a white page, neutral greys, ink text, framed cards, and exactly one accent. All of it is tokens and nine utilities — the tokens Vibra adds arrive with the first item you install, and the whole palette is one stylesheet to paste.
Install the theme
One item, and every other item already depends on it. It adds what Vibra adds on top of the shadcn token set — --surface, --faint-foreground, --brand, --rule, the status tones, the eight chart hues and the shadows, in both modes — plus the type scale, the base rules that set tabular figures, and the panel, frame, elev-1, elev-2, focus-ring and type-* utilities. It never writes --background, --foreground, --primary, --card, --border or --radius, so your own palette survives and the components wear it. The ground, the ink and the rules this site is set in are the full stylesheet at the bottom of this page, to paste into your globals.css.
$npx shadcn@latest add @vibra/theme-vibra
Needs the @vibra registry in your components.json — set it up once.
Titles are set in the sans, one size and one weight apart from the text under them, so the theme needs no font beyond the two a shadcn project already loads. If you want a display face of your own, load it as --font-display and every type-display follows — that token is the only thing that decides.
app/layout.tsx
import { Newsreader } from "next/font/google"// Optional: any face works, and only titles change.const display = Newsreader({ variable: "--font-display", subsets: ["latin"], display: "swap",})
Type carries the hierarchy
Six named registers, not a size ramp to pick from. Adjacent levels differ by one size step and one weight; nothing at or below 16px carries negative tracking. Numbers are the product, so tabular figures are the default rather than a class each cell has to remember.
type-eyebrow
Last 30 days
11/16 600 caps — group labels, meta, annotations
type-label
Signup rate
13/18 500 — stat labels, table heads, nav rows
text-sm
Traffic and signups for northwind.example
14/20 — body copy and table cells
text-md
Recent signups
15/22 500 — card and dialog titles, in the sans
type-display
A well-set report
20px and up — every title, in the display face
type-numeral
44,392
tabular figures, tracking on the numerals only
Teach your cn about the new sizes
The theme adds five font sizes — text-2xs, text-label, text-md, text-prose and text-avatar. A class merger decides what text-… means by looking the suffix up in Tailwind’s own scale, so it takes every one of these for a colour and drops it whenever it is written beside real ink — a small button keeps its size and loses its label colour. Register them once, in your lib/utils.ts:
Every grey is oklch at zero chroma, so nothing on the page is tinted but the data. Four planes carry the hierarchy — page, surface, sheet, floating layer — with the card frame and the sidebar on the surface and the sheets on white; in the dark the card is the lighter plane. A hairline never carries a boundary alone: a plane change does the work with it.
--background
the page sheet and the header bar
--surface
a card's frame, a table head, a filter bar
--card
the sheet inset in a card
--popover
anything floating above it
Rules, not boxes
Three line weights and a gridline, all opaque in both themes — a white/10% hairline composites to 1.32:1 on a dark card, which is why none of these is an alpha.
--border
row hairline and every ring
--rule
tab and table-head underline, section divider
--input
field boundary — 3:1, by law
--chart-grid
gridlines, painted directly
Three elevations, three recipes
Boxes are for sheets and floating layers only. panel is the one bordered- panel recipe on the site and frame the card’s outer box — a card is a frame holding a title strip and the content as an inset sheet; elev-1 and elev-2 are the floating and modal layers, and the only ones that cast a shadow. If you are writing rounded-lg border bg-card by hand, you want panel.
panelthe one bordered-panel recipe
framea card's outer box, on the surface
elev-1popovers, menus, tooltips
elev-2dialogs, sheets, the landing frame
One accent, five roles
--brand is the only accent. It appears in five places, and in a chart’s first slot too when it is a colour — a theme accented with the ink, like this one, names a grey of its own there instead, so a chart opens in the same ink as its numbers. --primary, --ring and --sidebar-primary are aliases of it inside the palette blocks, and --chart-1 joins them in a palette whose accent is a colour, so a preset re-points one token and every role follows. A decorative fill — an avatar fallback, a stepper connector, a notification dot — is ink or a raised control, never the accent.
1SaveThe default button fill
2OverviewThe active navigation row — a filled pill on the sidebar's accent plane
5DashboardsSelected rows, chips and pills, through the tint plus a rail and 500 weight
6NThe workspace mark — and chart series one, in a palette whose accent is a colour
Motion settles like paper
Three easings and three durations. Enter is a fade and a 4px rise on --ease-standard; a sheet, a dialog or a sliding indicator moves on --ease-emphasized; exit is faster, on --ease-exit. Reduced motion is a token switch rather than a blanket override — the durations go to 0ms under the media query and under data-motion="reduced", so nothing has to be written twice.
--duration-fast
120ms
120ms — an exit, a hover, a press
--duration-base
200ms
200ms — an enter, a reveal, a colour change
--duration-slow
320ms
320ms — a frame settling, a chart's first paint
--ease-standard
cubic-bezier(0.2, 0, 0, 1)
everything that enters
--ease-emphasized
cubic-bezier(0.3, 0, 0, 1)
a sheet, a dialog, a sliding indicator
--ease-exit
cubic-bezier(0.4, 0, 1, 1)
everything that leaves
Presets, including mono
Twenty-two palettes ship beside the base — the five of Vibra’s own, eleven drawn from the themes people already know, and six more — every one a complete theme in both modes. Among the first five: graphite (the default ladder with a low-chroma blue where the ink is), paper (the warm ground with an ochre accent), phosphor and mono, which re-points --brand at --foreground — the near-ink primary for a product that wants no accent at all. Set data-preset on <html>, or compare them on Themes.
Density
The rhythm a dashboard is laid out on is four variables, and a separate item from the palette — none of them is a colour. Add the file, then set data-density="compact" on <html> to tighten the page. The primitives read each variable with the comfortable value as a fallback, so a component installed without this file still lays out correctly.
$npx shadcn@latest add @vibra/density
Needs the @vibra registry in your components.json — set it up once.
app/vibra-density.css
/* Density — the rhythm a dashboard is laid out on, in one place so a table row, a card's padding and a grid's gap stay in proportion when it changes. Its own file, and its own registry item, because a project can want the density switch without wanting Vibra's palette: add the file, set `data-density` on <html>, and every primitive that sets a dashboard's rhythm follows. Deliberately not scoped to a preset: `[data-preset]` carries colour, and a palette that redefined these on a wrapper would reset the density chosen on <html> for everything inside it. The primitives read them with the comfortable value as a fallback (`py-[var(--density-cell-y,0.75rem)]`), so an item installed without this file still lays out correctly and only the switch is missing. */:root { --density-row: 2.75rem; --density-cell-y: 0.75rem; --density-card: 1rem; --density-gap: 1rem;}[data-density="compact"] { --density-row: 2.25rem; --density-cell-y: 0.375rem; --density-card: 0.75rem; --density-gap: 0.75rem;}
Source: the full palette
The complete stylesheet, both themes. Pasting it replaces shadcn’s --background, --primary, --card, --border, --radius and sidebar values, which is what makes it a whole theme rather than an addition.
app/globals.css
/* Vibra — a white page, neutral greys, and the ink as the one accent. * * The ladder is achromatic on purpose: every grey is oklch with zero chroma, * so nothing on the page is tinted but the data. Three planes carry hierarchy * (a white --background, a hair-grey --card on it, and --popover back at white * a step above the card; the order flips in the dark, where the card is the * lighter plane), lines come in three weights (--border hairline, --rule * divider, --input control boundary) and every one of them is opaque: a * white-alpha hairline composites to 1.3:1 on a dark card, which is a line * nobody can see. The accent is the ink itself, and it appears in five named * roles (CONVENTIONS.md §6); --primary, --ring and --sidebar-primary are * aliases of it, so a preset re-points one token and the whole page follows. * The conditional sixth role, --chart-1, follows the accent only while the * accent is a colour: here the accent is the ink, so the first chart slot is * a mid grey — the lightest that still clears 3:1 on the sheet, so a * one-series chart reads as a quiet shape rather than a block of ink — and * the hues start at the second slot. */:root { --radius: 0.625rem; --background: oklch(1 0 0); --surface: oklch(0.985 0 0); --card: oklch(1 0 0); --popover: oklch(1 0 0); --muted: oklch(0.965 0 0); --secondary: oklch(0.965 0 0); --accent: oklch(0.965 0 0); --foreground: oklch(0.145 0 0); --card-foreground: oklch(0.145 0 0); --popover-foreground: oklch(0.145 0 0); --muted-foreground: oklch(0.5 0 0); --faint-foreground: oklch(0.51 0 0); --secondary-foreground: oklch(0.205 0 0); --accent-foreground: oklch(0.205 0 0); --brand: oklch(0.205 0 0); --brand-foreground: oklch(0.985 0 0); --brand-muted: oklch(0.93 0 0); --primary: var(--brand); --primary-foreground: var(--brand-foreground); --ring: var(--brand); --border: oklch(0.905 0 0); --rule: oklch(0.87 0 0); --input: oklch(0.63 0 0); --chart-grid: oklch(0.905 0 0); --success: oklch(0.52 0.14 150); --success-foreground: oklch(0.99 0 0); --success-muted: oklch(0.95 0.04 150); --warning: oklch(0.54 0.11 75); --warning-foreground: oklch(0.99 0 0); --warning-muted: oklch(0.96 0.05 85); --danger: oklch(0.52 0.22 25); --danger-foreground: oklch(0.99 0 0); --danger-muted: oklch(0.95 0.03 25); --info: oklch(0.53 0.15 250); --info-foreground: oklch(0.99 0 0); --info-muted: oklch(0.95 0.03 250); --destructive: var(--danger); --chart-1: oklch(0.66 0 0); --chart-2: oklch(0.55 0.19 258); --chart-3: oklch(0.55 0.11 190); --chart-4: oklch(0.58 0.16 55); --chart-5: oklch(0.55 0.2 300); --chart-6: oklch(0.3 0 0); --chart-7: oklch(0.5 0 0); --chart-8: oklch(0.4 0 0); --chart-positive: var(--success); --chart-negative: var(--danger); --chart-neutral: var(--muted-foreground); --chart-axis: var(--muted-foreground); --sidebar: oklch(0.976 0 0); --sidebar-foreground: oklch(0.4 0 0); --sidebar-primary: var(--brand); --sidebar-primary-foreground: var(--brand-foreground); --sidebar-accent: oklch(0.92 0 0); --sidebar-accent-foreground: oklch(0.145 0 0); --sidebar-border: oklch(0.905 0 0); --sidebar-ring: var(--brand); --shadow-sheet: 0 0 0 0 transparent; --shadow-float: 0 8px 24px -8px oklch(0 0 0 / 18%), 0 1px 2px oklch(0 0 0 / 8%); --shadow-modal: 0 24px 48px -16px oklch(0 0 0 / 28%); --overlay: oklch(0 0 0 / 40%);}.dark { --background: oklch(0.145 0 0); --surface: oklch(0.185 0 0); --card: oklch(0.205 0 0); --popover: oklch(0.24 0 0); --muted: oklch(0.269 0 0); --secondary: oklch(0.269 0 0); --accent: oklch(0.269 0 0); --foreground: oklch(0.985 0 0); --card-foreground: oklch(0.985 0 0); --popover-foreground: oklch(0.985 0 0); --muted-foreground: oklch(0.71 0 0); --faint-foreground: oklch(0.72 0 0); --secondary-foreground: oklch(0.985 0 0); --accent-foreground: oklch(0.985 0 0); --brand: oklch(0.985 0 0); --brand-foreground: oklch(0.205 0 0); --brand-muted: oklch(0.3 0 0); --primary: var(--brand); --primary-foreground: var(--brand-foreground); --ring: var(--brand); --border: oklch(0.27 0 0); --rule: oklch(0.36 0 0); --input: oklch(0.6 0 0); --chart-grid: oklch(0.29 0 0); --success: oklch(0.72 0.17 150); --success-foreground: oklch(0.15 0.03 150); --success-muted: oklch(0.3 0.04 152); --warning: oklch(0.82 0.16 80); --warning-foreground: oklch(0.2 0.05 80); --warning-muted: oklch(0.31 0.04 82); --danger: oklch(0.78 0.14 27); --danger-foreground: oklch(0.15 0.03 25); --danger-muted: oklch(0.295 0.035 27); --info: oklch(0.7 0.15 250); --info-foreground: oklch(0.15 0.03 250); --info-muted: oklch(0.3 0.04 250); --destructive: var(--danger); --chart-1: oklch(0.75 0 0); --chart-2: oklch(0.68 0.16 258); --chart-3: oklch(0.72 0.12 190); --chart-4: oklch(0.75 0.15 60); --chart-5: oklch(0.7 0.17 300); --chart-6: oklch(0.88 0 0); --chart-7: oklch(0.6 0 0); --chart-8: oklch(0.54 0 0); --chart-positive: var(--success); --chart-negative: var(--danger); --chart-neutral: var(--muted-foreground); --chart-axis: var(--muted-foreground); --sidebar: oklch(0.205 0 0); --sidebar-foreground: oklch(0.8 0 0); --sidebar-primary: var(--brand); --sidebar-primary-foreground: var(--brand-foreground); --sidebar-accent: oklch(0.269 0 0); --sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-border: oklch(0.27 0 0); --sidebar-ring: var(--brand); --shadow-sheet: 0 0 0 0 transparent; --shadow-float: 0 12px 32px -8px oklch(0 0 0 / 60%), inset 0 1px 0 oklch(1 0 0 / 6%); --shadow-modal: 0 24px 64px -12px oklch(0 0 0 / 70%); --overlay: oklch(0 0 0 / 60%);}/* The face that titles at 20px and above are set in. It resolves to the sans: titles differ from body by size and weight, not by family, so a CLI consumer needs no font beyond the two a shadcn project already loads. In a cascade layer on purpose. next/font writes --font-display onto <html> from an unlayered class of its own, and an unlayered :root here would have the same specificity and a later source position — so this default would win and the webfont would never render. A layered declaration loses to an unlayered one whatever the order, which is exactly the priority wanted. */@layer base { :root { /* `type-display` reads this and nothing else decides, so a project that wants a display face of its own loads one through next/font as --font-display and changes nothing else. */ --font-display: var(--font-sans); }}/* Tracking and motion — the tokens a palette does not carry, so a preset never resets them. Tracking is scoped: nothing at or below 16px carries a negative value, only the titles tighten, and the numerals and the eyebrow sit on the face's own spacing — DM Sans is drawn wide enough that tracked figures crowd. */:root { --tracking-display: -0.025em; --tracking-heading: -0.015em; --tracking-numeral: 0; --tracking-eyebrow: 0; /* Motion — two easings, one exit, three speeds. Everything that moves reads one of these, so "less motion" is a single switch rather than a per-component opt-out: the media query answers the reader's OS setting, and [data-motion="reduced"] lets a page or a preview frame ask for the same. --ease-exit accelerates away, so a layer leaves faster than it arrived. */ --duration-fast: 120ms; --duration-base: 200ms; --duration-slow: 320ms; --ease-standard: cubic-bezier(0.2, 0, 0, 1); --ease-emphasized: cubic-bezier(0.3, 0, 0, 1); --ease-exit: cubic-bezier(0.4, 0, 1, 1);}@media (prefers-reduced-motion: reduce) { :root { --duration-fast: 0ms; --duration-base: 0ms; --duration-slow: 0ms; }}[data-motion="reduced"] { --duration-fast: 0ms; --duration-base: 0ms; --duration-slow: 0ms;}