installation

a few minutes of setup, then you can use any component. you'll need react 19 and tailwind css v4.

the quick way

run this in your project. it installs the package, adds the styles to your css, loads the font and sets up toasts in your layout, showing you each change before it makes it.

bash
npx @blurrow/ui init

later, npx @blurrow/ui doctor checks everything's still wired up, and npx @blurrow/ui doctor --fixrepairs your stylesheet if it isn't. rather do it by hand? here's what init does:

install the package

bash
npm install @blurrow/ui

add the styles

the components get their look from a small set of css tokens. pull them into your global stylesheet, and point tailwind at the package so it picks up the classes the components use.

app/globals.csscss
@import "tailwindcss";
@import "@blurrow/ui/styles.css";
/* let tailwind see the classes the components use */
@source "../node_modules/@blurrow/ui";

set up the font and toasts

everything is set in archivo. load it as --font-archivoand the tokens take it from there. if you'll use toasts, wrap your app in ToastProvider too.

app/layout.tsxtsx
import { Archivo } from "next/font/google";
import { ToastProvider } from "@blurrow/ui";
import "./globals.css";
const archivo = Archivo({ subsets: ["latin"], variable: "--font-archivo" });
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={archivo.variable}>
<body className="bg-[#EBEBEB] font-sans text-neutral-900 antialiased">
<ToastProvider>{children}</ToastProvider>
</body>
</html>
);
}

use it

that's it! import whatever you need. each component page has examples to copy from.

app/page.tsxtsx
import { Button } from "@blurrow/ui";
export default function Page() {
return <Button variant="primary">say hi</Button>;
}

rather copy the source?

that works too. npx @blurrow/ui add button tabs copies components (and anything they need) into your project and wires up the tokens; run it with no names to pick from a list. npx @blurrow/ui diffshows what you've changed since. or do it by hand: every component page has its full source under “rather own the code?”. drop the files into components/ui, save the tokens below as components/ui/styles.css, and import it right after tailwind in your globals.css in place of step 2.

components/ui/styles.csscss
/* blurrow/ui: design tokens, materials and keyframes.
Import after tailwindcss: @import "tailwindcss"; @import "@blurrow/ui/styles.css";
The kit is set in Archivo; load it as the --font-archivo variable. */
@theme {
--font-sans: var(--font-archivo), ui-sans-serif, system-ui, sans-serif;
}
/* ── Material tokens ─────────────────────────────────────────────── */
/* One light source from above: highlights live on top edges, shade on
bottom edges, and every raised face casts onto whatever holds it. */
:root {
--ease-spring: cubic-bezier(0.34, 1.4, 0.64, 1);
--ease-out-soft: cubic-bezier(0.16, 1, 0.3, 1);
/* Light material */
--bezel-light: linear-gradient(180deg, #f7f7f7 0%, #dedede 100%);
--bezel-light-shadow:
0 0 0 0.5px rgba(17, 17, 17, 0.054), 0 1px 1px rgba(17, 17, 17, 0.06),
0 4px 10px -4px rgba(17, 17, 17, 0.14), inset 0 2px 3px -2px rgba(255, 255, 255, 1),
inset 0 -2px 3px -2px rgba(17, 17, 17, 0.04);
--face-light: linear-gradient(180deg, #ffffff 0%, #fafafa 55%, #f3f3f3 100%);
--face-light-shadow:
0 0 3px rgba(17, 17, 17, 0.119),
0 1px 4px -1px rgba(17, 17, 17, 0.1), inset 0 2px 3px -2px rgba(255, 255, 255, 1),
inset 0 -2px 3px -2px rgba(17, 17, 17, 0.035);
/* Dark material */
--bezel-dark: linear-gradient(180deg, #2c2c2c 0%, #0c0c0c 100%);
--bezel-dark-shadow:
0 0 0 0.5px rgba(0, 0, 0, 0.33), 0 1px 1px rgba(0, 0, 0, 0.2),
0 4px 12px -4px rgba(0, 0, 0, 0.45), inset 0 2px 3px -2px rgba(255, 255, 255, 0.075);
--face-dark: linear-gradient(180deg, #2e2e2e 0%, #1f1f1f 55%, #181818 100%);
--face-dark-shadow:
0 0 3px rgba(0, 0, 0, 0.35),
0 1px 4px -1px rgba(0, 0, 0, 0.3), inset 0 2px 3px -2px rgba(255, 255, 255, 0.105),
inset 0 -2px 3px -2px rgba(0, 0, 0, 0.25);
/* Sunken face (pressed) — the face drops flush with its bezel */
--face-light-pressed:
0 0 3px rgba(17, 17, 17, 0.136), inset 0 1px 2px rgba(17, 17, 17, 0.08);
--face-dark-pressed:
0 0 3px rgba(0, 0, 0, 0.35), inset 0 1px 2px rgba(0, 0, 0, 0.45);
/* Fields — sunken: darker at the top, shade inside, a lit lip below.
Raised means "press me", sunken means "fill me".
Every state has the same five layers (edge, shade, lip, border, halo)
so box-shadow interpolates between them instead of snapping: hover
hints at the halo, focus lifts the well flush and draws the border. */
--field: linear-gradient(180deg, #f2f2f2 0%, #f8f8f8 100%);
--field-hover: linear-gradient(180deg, #eeeeee 0%, #f5f5f5 100%);
--field-shadow:
inset 0 0 2px rgba(17, 17, 17, 0.15), inset 0 1px 2px rgba(17, 17, 17, 0.06),
0 1px 2px -1px rgba(255, 255, 255, 0.72), inset 0 0 0 1px rgba(17, 17, 17, 0),
0 0 0 0 rgba(17, 17, 17, 0);
--field-shadow-hover:
inset 0 0 2px rgba(17, 17, 17, 0.25), inset 0 1px 2px rgba(17, 17, 17, 0.07),
0 1px 2px -1px rgba(255, 255, 255, 0.72), inset 0 0 0 1px rgba(17, 17, 17, 0.04),
0 0 0 3px rgba(17, 17, 17, 0.025);
--field-shadow-focus:
inset 0 0 2px rgba(17, 17, 17, 0.06), inset 0 1px 2px rgba(17, 17, 17, 0.02),
0 1px 2px -1px rgba(255, 255, 255, 0), inset 0 0 0 1px rgba(17, 17, 17, 0.42),
0 0 0 4px rgba(17, 17, 17, 0.07);
--field-shadow-error:
inset 0 0 2px rgba(220, 38, 38, 0.3), inset 0 1px 2px rgba(220, 38, 38, 0.06),
0 1px 2px -1px rgba(255, 255, 255, 0.72), inset 0 0 0 1px rgba(220, 38, 38, 0),
0 0 0 0 rgba(220, 38, 38, 0);
--field-shadow-error-hover:
inset 0 0 2px rgba(220, 38, 38, 0.4), inset 0 1px 2px rgba(220, 38, 38, 0.08),
0 1px 2px -1px rgba(255, 255, 255, 0.72), inset 0 0 0 1px rgba(220, 38, 38, 0.08),
0 0 0 3px rgba(220, 38, 38, 0.04);
--field-shadow-error-focus:
inset 0 0 2px rgba(220, 38, 38, 0.1), inset 0 1px 2px rgba(220, 38, 38, 0.02),
0 1px 2px -1px rgba(255, 255, 255, 0), inset 0 0 0 1px rgba(220, 38, 38, 0.7),
0 0 0 4px rgba(220, 38, 38, 0.1);
/* Wells hold a sliding thumb (tabs, pagination, segmented controls) */
--well: linear-gradient(180deg, #ebebeb 0%, #f1f1f1 100%);
--well-shadow:
inset 0 0 2px rgba(17, 17, 17, 0.09), inset 0 1px 2px rgba(17, 17, 17, 0.08),
0 1px 2px -1px rgba(255, 255, 255, 0.72);
--thumb-shadow:
0 0 3px rgba(17, 17, 17, 0.102), 0 1px 2px rgba(17, 17, 17, 0.1),
0 2px 6px -2px rgba(17, 17, 17, 0.08), inset 0 2px 3px -2px rgba(255, 255, 255, 1);
/* Panels — a soft bezel holding a white face (lists, menus, alerts) */
--panel-bezel: linear-gradient(180deg, #fbfbfb 0%, #f0f0f0 100%);
--panel-shadow:
0 0 0 0.5px rgba(17, 17, 17, 0.036), 0 1px 2px rgba(17, 17, 17, 0.04),
0 4px 12px -4px rgba(17, 17, 17, 0.08), inset 0 2px 3px -2px rgba(255, 255, 255, 1);
--panel-face-shadow:
0 0 3px rgba(17, 17, 17, 0.076), 0 1px 2px rgba(17, 17, 17, 0.05),
inset 0 2px 3px -2px rgba(255, 255, 255, 1);
/* Panels that float above the page cast further */
--overlay-shadow:
0 0 0 0.5px rgba(17, 17, 17, 0.048), 0 4px 12px -4px rgba(17, 17, 17, 0.12),
0 16px 40px -12px rgba(17, 17, 17, 0.2), inset 0 2px 3px -2px rgba(255, 255, 255, 1);
--modal-shadow:
0 0 0 0.5px rgba(17, 17, 17, 0.048), 0 8px 24px -8px rgba(17, 17, 17, 0.2),
0 32px 80px -16px rgba(17, 17, 17, 0.32), inset 0 2px 3px -2px rgba(255, 255, 255, 1);
--scrim: rgba(10, 10, 10, 0.4);
/* Cards — the largest surfaces; pair with .rim */
--card-light: linear-gradient(180deg, #ffffff 0%, #fefefe 40%, #fafafa 100%);
--card-light-shadow:
0 1px 3px rgba(17, 17, 17, 0.02), 0 4px 12px -2px rgba(17, 17, 17, 0.05),
0 8px 24px -4px rgba(17, 17, 17, 0.04), 0 0 0 0.5px rgba(17, 17, 17, 0.036),
inset 0 2.5px 3px -2px rgba(255, 255, 255, 0.712);
--card-light-shadow-hover:
0 2px 6px rgba(17, 17, 17, 0.03), 0 8px 24px -4px rgba(17, 17, 17, 0.08),
0 16px 48px -8px rgba(17, 17, 17, 0.1), 0 32px 96px -16px rgba(17, 17, 17, 0.08),
0 0 0 0.5px rgba(17, 17, 17, 0.048), inset 0 3px 3px -2px rgba(255, 255, 255, 1);
--card-dark:
radial-gradient(90% 60% at 50% 0%, rgba(255, 255, 255, 0.06), transparent 75%),
radial-gradient(140% 100% at 30% 0%, rgba(255, 255, 255, 0.08) 0%, rgba(255, 255, 255, 0) 65%),
linear-gradient(180deg, #121212 0%, #0a0a0a 100%);
/* Same lift as the light card — only the material changes, not the height */
--card-dark-shadow:
0 0 0 0.5px rgba(0, 0, 0, 0.33), 0 1px 3px rgba(17, 17, 17, 0.12),
0 4px 12px -2px rgba(17, 17, 17, 0.14), 0 12px 28px -8px rgba(17, 17, 17, 0.2),
inset 0 2px 3px -2px rgba(255, 255, 255, 0.075);
--card-dark-shadow-hover:
0 0 0 0.5px rgba(0, 0, 0, 0.54), 0 2px 6px rgba(17, 17, 17, 0.14),
0 10px 28px -6px rgba(17, 17, 17, 0.22), 0 28px 64px -16px rgba(17, 17, 17, 0.22),
inset 0 2px 3px -2px rgba(255, 255, 255, 0.09);
/* Card chassis is nearly the face's own tone — the hairline separator
between them does the dividing, not a change of colour. */
--card-bezel-light: linear-gradient(180deg, #fdfdfd 0%, #f7f7f7 100%);
--card-face-light-shadow:
0 0 0 0.5px rgba(17, 17, 17, 0.042), 0 1px 2px rgba(17, 17, 17, 0.04),
inset 0 2px 3px -2px rgba(255, 255, 255, 1);
--card-bezel-dark: linear-gradient(180deg, #151515 0%, #0e0e0e 100%);
--card-face-dark-shadow:
0 0 0 0.5px rgba(255, 255, 255, 0.042), 0 1px 2px rgba(0, 0, 0, 0.4),
inset 0 2px 3px -2px rgba(255, 255, 255, 0.045);
/* Empty media slots: a whisper darker than the face they sit in */
--media-well-light: linear-gradient(180deg, rgba(17, 17, 17, 0.025) 0%, rgba(17, 17, 17, 0.04) 100%);
--media-well-dark: linear-gradient(180deg, rgba(0, 0, 0, 0.18) 0%, rgba(0, 0, 0, 0.26) 100%);
/* Icon tiles on cards — small raised faces */
--tile-light-shadow:
0 0 3px rgba(17, 17, 17, 0.102), 0 1px 2px rgba(17, 17, 17, 0.08),
inset 0 2px 3px -2px rgba(255, 255, 255, 1);
--tile-dark: linear-gradient(180deg, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0.04) 100%);
--tile-dark-shadow:
0 0 0 0.5px rgba(255, 255, 255, 0.048), 0 2px 6px rgba(0, 0, 0, 0.35),
inset 0 2px 3px -2px rgba(255, 255, 255, 0.09);
}
/* ── Scrollbars inside panels ────────────────────────────────────── */
.scroll-thin {
scrollbar-width: thin;
scrollbar-color: rgba(17, 17, 17, 0.15) transparent;
}
/* ── Rim light ───────────────────────────────────────────────────── */
/* A hairline border that is bright at the top and fades by the middle,
like light catching the edge of a surface. Set --rim to tune it. */
.rim {
position: relative;
}
.rim::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
padding: 1px;
background: linear-gradient(180deg, var(--rim, rgba(255, 255, 255, 0.9)) 0%, transparent 55%);
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
z-index: 1;
}
/* ── Field ───────────────────────────────────────────────────────── */
/* Gradients can't transition, but registered colours can — so the well's
two stops are animatable properties and focus fades it up to white
rather than swapping it in a single frame. */
@property --field-top {
syntax: '<color>';
inherits: false;
initial-value: #f2f2f2;
}
@property --field-bottom {
syntax: '<color>';
inherits: false;
initial-value: #f8f8f8;
}
@layer components {
.field {
--field-top: #f2f2f2;
--field-bottom: #f8f8f8;
background: linear-gradient(180deg, var(--field-top) 0%, var(--field-bottom) 100%);
box-shadow: var(--field-shadow);
transition:
--field-top 260ms var(--ease-out-soft),
--field-bottom 260ms var(--ease-out-soft),
box-shadow 260ms var(--ease-out-soft);
}
.field:hover {
--field-top: #eeeeee;
--field-bottom: #f5f5f5;
box-shadow: var(--field-shadow-hover);
}
.field:focus-visible,
.field:is(input, textarea):focus,
.field[data-open] {
--field-top: #ffffff;
--field-bottom: #ffffff;
outline: none;
box-shadow: var(--field-shadow-focus);
}
.field[aria-invalid='true'] {
box-shadow: var(--field-shadow-error);
}
.field[aria-invalid='true']:hover {
box-shadow: var(--field-shadow-error-hover);
}
.field[aria-invalid='true']:focus {
box-shadow: var(--field-shadow-error-focus);
}
}
/* ── Button ──────────────────────────────────────────────────────── */
/* Trailing icons lean toward where the button is taking you. */
.btn-icon-nudge > svg:last-child:not(:first-child) {
transition: transform 320ms var(--ease-spring);
}
[data-btn]:hover .btn-icon-nudge > svg:last-child:not(:first-child) {
transform: translateX(2px);
}
/* Motion is decoration; when someone asks for less, every component
still lands in its final state, just without the journey. */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 1ms !important;
animation-iteration-count: 1 !important;
transition-duration: 1ms !important;
}
}
/* ── Carousel ────────────────────────────────────────────────────── */
@keyframes carousel-progress {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
.carousel-progress { animation: carousel-progress linear both; }
/* ── Checkbox / Radio mark ──────────────────────────────────── */
@keyframes checkbox-in {
0% { transform: scale(0); opacity: 0; }
55% { transform: scale(1.25); opacity: 1; }
100% { transform: scale(1); opacity: 1; }
}
.animate-checkbox-in {
animation: checkbox-in 220ms cubic-bezier(0.34, 1.2, 0.64, 1) both;
display: flex;
align-items: center;
justify-content: center;
}
@keyframes draw-checkmark {
from { stroke-dashoffset: 15; }
to { stroke-dashoffset: 0; }
}
.animate-draw-check {
stroke-dasharray: 15;
stroke-dashoffset: 15;
animation: draw-checkmark 200ms cubic-bezier(0.65, 0, 0.35, 1) 80ms both;
}
/* ── Tab panel ───────────────────────────────────────────────────── */
@keyframes tab-slide-right {
from { opacity: 0; transform: translateX(20px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes tab-slide-left {
from { opacity: 0; transform: translateX(-20px); }
to { opacity: 1; transform: translateX(0); }
}
.animate-tab-slide-right {
animation: tab-slide-right 280ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.animate-tab-slide-left {
animation: tab-slide-left 280ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
/* ── Modal ───────────────────────────────────────────────────────── */
@keyframes modal-in {
from { opacity: 0; transform: scale(0.94) translateY(12px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
@keyframes modal-out {
from { opacity: 1; transform: scale(1) translateY(0); }
to { opacity: 0; transform: scale(0.96) translateY(8px); }
}
.animate-modal-in { animation: modal-in 300ms cubic-bezier(0.34,1.4,0.64,1) both; }
.animate-modal-out { animation: modal-out 220ms ease-in both; }
@keyframes backdrop-in { from { opacity: 0; } to { opacity: 1; } }
@keyframes backdrop-out { from { opacity: 1; } to { opacity: 0; } }
.animate-backdrop-in { animation: backdrop-in 260ms ease-out both; }
.animate-backdrop-out { animation: backdrop-out 220ms ease-in both; }
/* ── Tooltip (handled via inline transitions) ────────────────────── */
/* ── Input / Select shimmer sweep ────────────────────────────────── */
@keyframes input-shimmer {
0% { transform: translateX(0%); opacity: 0; }
12% { opacity: 1; }
88% { opacity: 1; }
100% { transform: translateX(420%); opacity: 0; }
}
.animate-input-shimmer {
animation: input-shimmer 850ms cubic-bezier(0.4, 0, 0.2, 1) both;
}
@keyframes select-in {
from { opacity: 0; transform: scale(0.97) translateY(-4px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
@keyframes select-option-in {
from { opacity: 0; transform: translateY(-3px); }
to { opacity: 1; transform: translateY(0); }
}
/* ── Pagination number shift ─────────────────────────────────────── */
@keyframes page-in-up {
from { opacity: 0; transform: translateY(7px); filter: blur(2px); }
to { opacity: 1; transform: translateY(0); filter: blur(0); }
}
@keyframes page-in-down {
from { opacity: 0; transform: translateY(-7px); filter: blur(2px); }
to { opacity: 1; transform: translateY(0); filter: blur(0); }
}
.animate-page-in-up {
animation: page-in-up 260ms cubic-bezier(0.16,1,0.3,1) both;
}
.animate-page-in-down {
animation: page-in-down 260ms cubic-bezier(0.16,1,0.3,1) both;
}
/* ── Toast ───────────────────────────────────────────────────────── */
@keyframes toast-in {
from { opacity: 0; transform: translateY(-14px) scale(0.97); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes toast-out {
from { opacity: 1; transform: translateY(0) scale(1); }
to { opacity: 0; transform: translateY(-10px) scale(0.97); }
}
.animate-toast-in {
animation: toast-in 320ms cubic-bezier(0.34, 1.4, 0.64, 1) both;
}
.animate-toast-out {
animation: toast-out 180ms cubic-bezier(0.4, 0, 1, 1) both;
}
/* ── Popover (handled via inline transitions) ────────────────────── */
/* ── Skeleton ────────────────────────────────────────────────────── */
@keyframes skeleton-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.animate-skeleton-pulse {
animation: skeleton-pulse 1.8s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
/* A band of light crossing the empty well, fixed to the viewport so
every placeholder on screen sweeps in sync. */
@keyframes skeleton-sheen {
from { background-position: -60vw 0; }
to { background-position: 60vw 0; }
}
.skeleton-sheen::after {
content: '';
position: absolute;
inset: 0;
background-image: linear-gradient(
90deg,
transparent 0%,
rgba(255, 255, 255, 0.75) 50%,
transparent 100%
);
background-size: 40vw 100%;
background-repeat: no-repeat;
background-attachment: fixed;
animation: skeleton-sheen 1.6s cubic-bezier(0.4, 0, 0.2, 1) infinite;
}
@media (prefers-reduced-motion: reduce) {
.skeleton-sheen::after { animation: none; display: none; }
}