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.
npx @blurrow/ui initlater, 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
npm install @blurrow/uiadd 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.
@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.
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.
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.
1/* blurrow/ui: design tokens, materials and keyframes.2 Import after tailwindcss: @import "tailwindcss"; @import "@blurrow/ui/styles.css";3 The kit is set in Archivo; load it as the --font-archivo variable. */4
5@theme {6 --font-sans: var(--font-archivo), ui-sans-serif, system-ui, sans-serif;7}8
9/* ── Material tokens ─────────────────────────────────────────────── */10/* One light source from above: highlights live on top edges, shade on11 bottom edges, and every raised face casts onto whatever holds it. */12:root {13 --ease-spring: cubic-bezier(0.34, 1.4, 0.64, 1);14 --ease-out-soft: cubic-bezier(0.16, 1, 0.3, 1);15
16 /* Light material */17 --bezel-light: linear-gradient(180deg, #f7f7f7 0%, #dedede 100%);18 --bezel-light-shadow:19 0 0 0 0.5px rgba(17, 17, 17, 0.054), 0 1px 1px rgba(17, 17, 17, 0.06),20 0 4px 10px -4px rgba(17, 17, 17, 0.14), inset 0 2px 3px -2px rgba(255, 255, 255, 1),21 inset 0 -2px 3px -2px rgba(17, 17, 17, 0.04);22 --face-light: linear-gradient(180deg, #ffffff 0%, #fafafa 55%, #f3f3f3 100%);23 --face-light-shadow:24 0 0 3px rgba(17, 17, 17, 0.119),25 0 1px 4px -1px rgba(17, 17, 17, 0.1), inset 0 2px 3px -2px rgba(255, 255, 255, 1),26 inset 0 -2px 3px -2px rgba(17, 17, 17, 0.035);27
28 /* Dark material */29 --bezel-dark: linear-gradient(180deg, #2c2c2c 0%, #0c0c0c 100%);30 --bezel-dark-shadow:31 0 0 0 0.5px rgba(0, 0, 0, 0.33), 0 1px 1px rgba(0, 0, 0, 0.2),32 0 4px 12px -4px rgba(0, 0, 0, 0.45), inset 0 2px 3px -2px rgba(255, 255, 255, 0.075);33 --face-dark: linear-gradient(180deg, #2e2e2e 0%, #1f1f1f 55%, #181818 100%);34 --face-dark-shadow:35 0 0 3px rgba(0, 0, 0, 0.35),36 0 1px 4px -1px rgba(0, 0, 0, 0.3), inset 0 2px 3px -2px rgba(255, 255, 255, 0.105),37 inset 0 -2px 3px -2px rgba(0, 0, 0, 0.25);38
39 /* Sunken face (pressed) — the face drops flush with its bezel */40 --face-light-pressed:41 0 0 3px rgba(17, 17, 17, 0.136), inset 0 1px 2px rgba(17, 17, 17, 0.08);42 --face-dark-pressed:43 0 0 3px rgba(0, 0, 0, 0.35), inset 0 1px 2px rgba(0, 0, 0, 0.45);44
45 /* Fields — sunken: darker at the top, shade inside, a lit lip below.46 Raised means "press me", sunken means "fill me".47
48 Every state has the same five layers (edge, shade, lip, border, halo)49 so box-shadow interpolates between them instead of snapping: hover50 hints at the halo, focus lifts the well flush and draws the border. */51 --field: linear-gradient(180deg, #f2f2f2 0%, #f8f8f8 100%);52 --field-hover: linear-gradient(180deg, #eeeeee 0%, #f5f5f5 100%);53 --field-shadow:54 inset 0 0 2px rgba(17, 17, 17, 0.15), inset 0 1px 2px rgba(17, 17, 17, 0.06),55 0 1px 2px -1px rgba(255, 255, 255, 0.72), inset 0 0 0 1px rgba(17, 17, 17, 0),56 0 0 0 0 rgba(17, 17, 17, 0);57 --field-shadow-hover:58 inset 0 0 2px rgba(17, 17, 17, 0.25), inset 0 1px 2px rgba(17, 17, 17, 0.07),59 0 1px 2px -1px rgba(255, 255, 255, 0.72), inset 0 0 0 1px rgba(17, 17, 17, 0.04),60 0 0 0 3px rgba(17, 17, 17, 0.025);61 --field-shadow-focus:62 inset 0 0 2px rgba(17, 17, 17, 0.06), inset 0 1px 2px rgba(17, 17, 17, 0.02),63 0 1px 2px -1px rgba(255, 255, 255, 0), inset 0 0 0 1px rgba(17, 17, 17, 0.42),64 0 0 0 4px rgba(17, 17, 17, 0.07);65 --field-shadow-error:66 inset 0 0 2px rgba(220, 38, 38, 0.3), inset 0 1px 2px rgba(220, 38, 38, 0.06),67 0 1px 2px -1px rgba(255, 255, 255, 0.72), inset 0 0 0 1px rgba(220, 38, 38, 0),68 0 0 0 0 rgba(220, 38, 38, 0);69 --field-shadow-error-hover:70 inset 0 0 2px rgba(220, 38, 38, 0.4), inset 0 1px 2px rgba(220, 38, 38, 0.08),71 0 1px 2px -1px rgba(255, 255, 255, 0.72), inset 0 0 0 1px rgba(220, 38, 38, 0.08),72 0 0 0 3px rgba(220, 38, 38, 0.04);73 --field-shadow-error-focus:74 inset 0 0 2px rgba(220, 38, 38, 0.1), inset 0 1px 2px rgba(220, 38, 38, 0.02),75 0 1px 2px -1px rgba(255, 255, 255, 0), inset 0 0 0 1px rgba(220, 38, 38, 0.7),76 0 0 0 4px rgba(220, 38, 38, 0.1);77
78 /* Wells hold a sliding thumb (tabs, pagination, segmented controls) */79 --well: linear-gradient(180deg, #ebebeb 0%, #f1f1f1 100%);80 --well-shadow:81 inset 0 0 2px rgba(17, 17, 17, 0.09), inset 0 1px 2px rgba(17, 17, 17, 0.08),82 0 1px 2px -1px rgba(255, 255, 255, 0.72);83 --thumb-shadow:84 0 0 3px rgba(17, 17, 17, 0.102), 0 1px 2px rgba(17, 17, 17, 0.1),85 0 2px 6px -2px rgba(17, 17, 17, 0.08), inset 0 2px 3px -2px rgba(255, 255, 255, 1);86
87 /* Panels — a soft bezel holding a white face (lists, menus, alerts) */88 --panel-bezel: linear-gradient(180deg, #fbfbfb 0%, #f0f0f0 100%);89 --panel-shadow:90 0 0 0 0.5px rgba(17, 17, 17, 0.036), 0 1px 2px rgba(17, 17, 17, 0.04),91 0 4px 12px -4px rgba(17, 17, 17, 0.08), inset 0 2px 3px -2px rgba(255, 255, 255, 1);92 --panel-face-shadow:93 0 0 3px rgba(17, 17, 17, 0.076), 0 1px 2px rgba(17, 17, 17, 0.05),94 inset 0 2px 3px -2px rgba(255, 255, 255, 1);95 /* Panels that float above the page cast further */96 --overlay-shadow:97 0 0 0 0.5px rgba(17, 17, 17, 0.048), 0 4px 12px -4px rgba(17, 17, 17, 0.12),98 0 16px 40px -12px rgba(17, 17, 17, 0.2), inset 0 2px 3px -2px rgba(255, 255, 255, 1);99 --modal-shadow:100 0 0 0 0.5px rgba(17, 17, 17, 0.048), 0 8px 24px -8px rgba(17, 17, 17, 0.2),101 0 32px 80px -16px rgba(17, 17, 17, 0.32), inset 0 2px 3px -2px rgba(255, 255, 255, 1);102 --scrim: rgba(10, 10, 10, 0.4);103
104 /* Cards — the largest surfaces; pair with .rim */105 --card-light: linear-gradient(180deg, #ffffff 0%, #fefefe 40%, #fafafa 100%);106 --card-light-shadow:107 0 1px 3px rgba(17, 17, 17, 0.02), 0 4px 12px -2px rgba(17, 17, 17, 0.05),108 0 8px 24px -4px rgba(17, 17, 17, 0.04), 0 0 0 0.5px rgba(17, 17, 17, 0.036),109 inset 0 2.5px 3px -2px rgba(255, 255, 255, 0.712);110 --card-light-shadow-hover:111 0 2px 6px rgba(17, 17, 17, 0.03), 0 8px 24px -4px rgba(17, 17, 17, 0.08),112 0 16px 48px -8px rgba(17, 17, 17, 0.1), 0 32px 96px -16px rgba(17, 17, 17, 0.08),113 0 0 0 0.5px rgba(17, 17, 17, 0.048), inset 0 3px 3px -2px rgba(255, 255, 255, 1);114 --card-dark:115 radial-gradient(90% 60% at 50% 0%, rgba(255, 255, 255, 0.06), transparent 75%),116 radial-gradient(140% 100% at 30% 0%, rgba(255, 255, 255, 0.08) 0%, rgba(255, 255, 255, 0) 65%),117 linear-gradient(180deg, #121212 0%, #0a0a0a 100%);118 /* Same lift as the light card — only the material changes, not the height */119 --card-dark-shadow:120 0 0 0 0.5px rgba(0, 0, 0, 0.33), 0 1px 3px rgba(17, 17, 17, 0.12),121 0 4px 12px -2px rgba(17, 17, 17, 0.14), 0 12px 28px -8px rgba(17, 17, 17, 0.2),122 inset 0 2px 3px -2px rgba(255, 255, 255, 0.075);123 --card-dark-shadow-hover:124 0 0 0 0.5px rgba(0, 0, 0, 0.54), 0 2px 6px rgba(17, 17, 17, 0.14),125 0 10px 28px -6px rgba(17, 17, 17, 0.22), 0 28px 64px -16px rgba(17, 17, 17, 0.22),126 inset 0 2px 3px -2px rgba(255, 255, 255, 0.09);127
128 /* Card chassis is nearly the face's own tone — the hairline separator129 between them does the dividing, not a change of colour. */130 --card-bezel-light: linear-gradient(180deg, #fdfdfd 0%, #f7f7f7 100%);131 --card-face-light-shadow:132 0 0 0 0.5px rgba(17, 17, 17, 0.042), 0 1px 2px rgba(17, 17, 17, 0.04),133 inset 0 2px 3px -2px rgba(255, 255, 255, 1);134 --card-bezel-dark: linear-gradient(180deg, #151515 0%, #0e0e0e 100%);135 --card-face-dark-shadow:136 0 0 0 0.5px rgba(255, 255, 255, 0.042), 0 1px 2px rgba(0, 0, 0, 0.4),137 inset 0 2px 3px -2px rgba(255, 255, 255, 0.045);138
139 /* Empty media slots: a whisper darker than the face they sit in */140 --media-well-light: linear-gradient(180deg, rgba(17, 17, 17, 0.025) 0%, rgba(17, 17, 17, 0.04) 100%);141 --media-well-dark: linear-gradient(180deg, rgba(0, 0, 0, 0.18) 0%, rgba(0, 0, 0, 0.26) 100%);142
143 /* Icon tiles on cards — small raised faces */144 --tile-light-shadow:145 0 0 3px rgba(17, 17, 17, 0.102), 0 1px 2px rgba(17, 17, 17, 0.08),146 inset 0 2px 3px -2px rgba(255, 255, 255, 1);147 --tile-dark: linear-gradient(180deg, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0.04) 100%);148 --tile-dark-shadow:149 0 0 0 0.5px rgba(255, 255, 255, 0.048), 0 2px 6px rgba(0, 0, 0, 0.35),150 inset 0 2px 3px -2px rgba(255, 255, 255, 0.09);151}152
153/* ── Scrollbars inside panels ────────────────────────────────────── */154.scroll-thin {155 scrollbar-width: thin;156 scrollbar-color: rgba(17, 17, 17, 0.15) transparent;157}158
159/* ── Rim light ───────────────────────────────────────────────────── */160/* A hairline border that is bright at the top and fades by the middle,161 like light catching the edge of a surface. Set --rim to tune it. */162.rim {163 position: relative;164}165.rim::before {166 content: '';167 position: absolute;168 inset: 0;169 border-radius: inherit;170 padding: 1px;171 background: linear-gradient(180deg, var(--rim, rgba(255, 255, 255, 0.9)) 0%, transparent 55%);172 -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);173 -webkit-mask-composite: xor;174 mask-composite: exclude;175 pointer-events: none;176 z-index: 1;177}178
179/* ── Field ───────────────────────────────────────────────────────── */180/* Gradients can't transition, but registered colours can — so the well's181 two stops are animatable properties and focus fades it up to white182 rather than swapping it in a single frame. */183@property --field-top {184 syntax: '<color>';185 inherits: false;186 initial-value: #f2f2f2;187}188@property --field-bottom {189 syntax: '<color>';190 inherits: false;191 initial-value: #f8f8f8;192}193@layer components {194 .field {195 --field-top: #f2f2f2;196 --field-bottom: #f8f8f8;197 background: linear-gradient(180deg, var(--field-top) 0%, var(--field-bottom) 100%);198 box-shadow: var(--field-shadow);199 transition:200 --field-top 260ms var(--ease-out-soft),201 --field-bottom 260ms var(--ease-out-soft),202 box-shadow 260ms var(--ease-out-soft);203 }204 .field:hover {205 --field-top: #eeeeee;206 --field-bottom: #f5f5f5;207 box-shadow: var(--field-shadow-hover);208 }209 .field:focus-visible,210 .field:is(input, textarea):focus,211 .field[data-open] {212 --field-top: #ffffff;213 --field-bottom: #ffffff;214 outline: none;215 box-shadow: var(--field-shadow-focus);216 }217 .field[aria-invalid='true'] {218 box-shadow: var(--field-shadow-error);219 }220 .field[aria-invalid='true']:hover {221 box-shadow: var(--field-shadow-error-hover);222 }223 .field[aria-invalid='true']:focus {224 box-shadow: var(--field-shadow-error-focus);225 }226}227
228/* ── Button ──────────────────────────────────────────────────────── */229/* Trailing icons lean toward where the button is taking you. */230.btn-icon-nudge > svg:last-child:not(:first-child) {231 transition: transform 320ms var(--ease-spring);232}233[data-btn]:hover .btn-icon-nudge > svg:last-child:not(:first-child) {234 transform: translateX(2px);235}236
237/* Motion is decoration; when someone asks for less, every component238 still lands in its final state, just without the journey. */239@media (prefers-reduced-motion: reduce) {240 *,241 *::before,242 *::after {243 animation-duration: 1ms !important;244 animation-iteration-count: 1 !important;245 transition-duration: 1ms !important;246 }247}248
249/* ── Carousel ────────────────────────────────────────────────────── */250@keyframes carousel-progress {251 from { transform: scaleX(0); }252 to { transform: scaleX(1); }253}254.carousel-progress { animation: carousel-progress linear both; }255
256/* ── Checkbox / Radio mark ──────────────────────────────────── */257@keyframes checkbox-in {258 0% { transform: scale(0); opacity: 0; }259 55% { transform: scale(1.25); opacity: 1; }260 100% { transform: scale(1); opacity: 1; }261}262.animate-checkbox-in {263 animation: checkbox-in 220ms cubic-bezier(0.34, 1.2, 0.64, 1) both;264 display: flex;265 align-items: center;266 justify-content: center;267}268
269@keyframes draw-checkmark {270 from { stroke-dashoffset: 15; }271 to { stroke-dashoffset: 0; }272}273.animate-draw-check {274 stroke-dasharray: 15;275 stroke-dashoffset: 15;276 animation: draw-checkmark 200ms cubic-bezier(0.65, 0, 0.35, 1) 80ms both;277}278
279/* ── Tab panel ───────────────────────────────────────────────────── */280@keyframes tab-slide-right {281 from { opacity: 0; transform: translateX(20px); }282 to { opacity: 1; transform: translateX(0); }283}284@keyframes tab-slide-left {285 from { opacity: 0; transform: translateX(-20px); }286 to { opacity: 1; transform: translateX(0); }287}288.animate-tab-slide-right {289 animation: tab-slide-right 280ms cubic-bezier(0.16, 1, 0.3, 1) both;290}291.animate-tab-slide-left {292 animation: tab-slide-left 280ms cubic-bezier(0.16, 1, 0.3, 1) both;293}294
295/* ── Modal ───────────────────────────────────────────────────────── */296@keyframes modal-in {297 from { opacity: 0; transform: scale(0.94) translateY(12px); }298 to { opacity: 1; transform: scale(1) translateY(0); }299}300@keyframes modal-out {301 from { opacity: 1; transform: scale(1) translateY(0); }302 to { opacity: 0; transform: scale(0.96) translateY(8px); }303}304.animate-modal-in { animation: modal-in 300ms cubic-bezier(0.34,1.4,0.64,1) both; }305.animate-modal-out { animation: modal-out 220ms ease-in both; }306
307@keyframes backdrop-in { from { opacity: 0; } to { opacity: 1; } }308@keyframes backdrop-out { from { opacity: 1; } to { opacity: 0; } }309.animate-backdrop-in { animation: backdrop-in 260ms ease-out both; }310.animate-backdrop-out { animation: backdrop-out 220ms ease-in both; }311
312/* ── Tooltip (handled via inline transitions) ────────────────────── */313
314/* ── Input / Select shimmer sweep ────────────────────────────────── */315@keyframes input-shimmer {316 0% { transform: translateX(0%); opacity: 0; }317 12% { opacity: 1; }318 88% { opacity: 1; }319 100% { transform: translateX(420%); opacity: 0; }320}321.animate-input-shimmer {322 animation: input-shimmer 850ms cubic-bezier(0.4, 0, 0.2, 1) both;323}324
325@keyframes select-in {326 from { opacity: 0; transform: scale(0.97) translateY(-4px); }327 to { opacity: 1; transform: scale(1) translateY(0); }328}329
330@keyframes select-option-in {331 from { opacity: 0; transform: translateY(-3px); }332 to { opacity: 1; transform: translateY(0); }333}334
335/* ── Pagination number shift ─────────────────────────────────────── */336@keyframes page-in-up {337 from { opacity: 0; transform: translateY(7px); filter: blur(2px); }338 to { opacity: 1; transform: translateY(0); filter: blur(0); }339}340@keyframes page-in-down {341 from { opacity: 0; transform: translateY(-7px); filter: blur(2px); }342 to { opacity: 1; transform: translateY(0); filter: blur(0); }343}344.animate-page-in-up {345 animation: page-in-up 260ms cubic-bezier(0.16,1,0.3,1) both;346}347.animate-page-in-down {348 animation: page-in-down 260ms cubic-bezier(0.16,1,0.3,1) both;349}350
351/* ── Toast ───────────────────────────────────────────────────────── */352@keyframes toast-in {353 from { opacity: 0; transform: translateY(-14px) scale(0.97); }354 to { opacity: 1; transform: translateY(0) scale(1); }355}356@keyframes toast-out {357 from { opacity: 1; transform: translateY(0) scale(1); }358 to { opacity: 0; transform: translateY(-10px) scale(0.97); }359}360.animate-toast-in {361 animation: toast-in 320ms cubic-bezier(0.34, 1.4, 0.64, 1) both;362}363.animate-toast-out {364 animation: toast-out 180ms cubic-bezier(0.4, 0, 1, 1) both;365}366
367/* ── Popover (handled via inline transitions) ────────────────────── */368
369/* ── Skeleton ────────────────────────────────────────────────────── */370@keyframes skeleton-pulse {371 0%, 100% { opacity: 1; }372 50% { opacity: 0.4; }373}374.animate-skeleton-pulse {375 animation: skeleton-pulse 1.8s cubic-bezier(0.4, 0, 0.6, 1) infinite;376}377
378/* A band of light crossing the empty well, fixed to the viewport so379 every placeholder on screen sweeps in sync. */380@keyframes skeleton-sheen {381 from { background-position: -60vw 0; }382 to { background-position: 60vw 0; }383}384.skeleton-sheen::after {385 content: '';386 position: absolute;387 inset: 0;388 background-image: linear-gradient(389 90deg,390 transparent 0%,391 rgba(255, 255, 255, 0.75) 50%,392 transparent 100%393 );394 background-size: 40vw 100%;395 background-repeat: no-repeat;396 background-attachment: fixed;397 animation: skeleton-sheen 1.6s cubic-bezier(0.4, 0, 0.2, 1) infinite;398}399@media (prefers-reduced-motion: reduce) {400 .skeleton-sheen::after { animation: none; display: none; }401}