1.1 Colour
Eighteen named colours, four font families, one hero accent. The default surface is warm cream — never default to dark. Use semantic tokens (--fg, --bg, --accent) in component CSS; reserve the raw palette names for the token file.
Ink & paper
Warm neutrals (default surface family)
Block quad — pink is hero
Semantic — resolved tokens
Token reference
CSS custom propertiesEvery component CSS selector in this library reads from tokens below. Flipping [data-theme="dark"] on <body> remaps the semantic layer in place.
/* Always reference tokens, never literal hex values,
so theme-switching flips automatically. */
color: var(--fg); /* body text */
background: var(--bg); /* page surface */
background: var(--bg-paper); /* cards / panels */
background: var(--bg-sunk); /* code blocks, chips */
border-color: var(--hair); /* 12% ink — default hairline */
border-color: var(--hair-soft); /* 6% ink — subliminal */
color: var(--fg-soft); /* secondary copy */
color: var(--fg-dim); /* 55% ink — metadata */
color: var(--fg-faint); /* 35% ink — whispers */
background: var(--accent-soft); /* 12% pink — bg for pill/alert */
// Tokens flow into React via the Tailwind preset shipped with
// @magicblocksai/ui. Import the CSS once at the app root, then
// reference tokens by their Tailwind class names — never hex.
import "@magicblocksai/css";
export default function Card() {
return (
<div className="bg-paper text-fg border border-hair rounded-md p-s-5">
<p className="text-fg-soft">Secondary copy</p>
<span className="bg-accent-soft text-accent-text px-s-2 rounded-pill">
Pink pill
</span>
</div>
);
}1.2 Spacing scale
A 4-step base multiplied up to 128px. Use tokens, not magic numbers. Most components live between --s-3 and --s-7; heroes and chapter heads reach for --s-10 upward.
The ruler
--s-1 → --s-13Lowest values for compact inline gaps; highest values for page rhythm. Skipping a step is usually a sign you need a different token, not a fractional one.
/* Use spacing tokens everywhere — never hard-coded px. */
.card { padding: var(--s-7); gap: var(--s-5); }
.section { padding: var(--s-11) 0; }
.button { padding: var(--s-3) var(--s-5); }
.stack > * + * { margin-top: var(--s-4); }
// Tailwind preset (shipped with @magicblocksai/ui) maps every
// --s-N token to a corresponding utility — p-s-7, gap-s-5, etc.
export default function Card() {
return (
<article className="p-s-7 gap-s-5 flex flex-col bg-paper rounded-md">
<header className="mb-s-4">Card title</header>
<p className="text-fg-soft">Body copy.</p>
</article>
);
}
// Or read the token directly via inline style for dynamic spacing:
<div style={{ padding: "var(--s-5)", gap: "var(--s-3)", display: "flex", flexDirection: "column" }}>
<div className="card">Top</div>
<div className="card">Bottom</div>
</div>1.3 Radii
Seven steps from hairline chip to full pill. Friendly but restrained — the whole system leans warm, so radii should too. Avoid mixing non-token radii on the same surface.
Radius tokens
--r-xs → --r-pill.chip { border-radius: var(--r-xs); }
.input { border-radius: var(--r-sm); }
.button { border-radius: var(--r-md); }
.card { border-radius: var(--r-lg); }
.hero { border-radius: var(--r-2xl); }
.avatar, .pill { border-radius: var(--r-pill); }
// Tailwind preset maps each radius token to a utility:
// rounded-xs · rounded-sm · rounded-md · rounded-lg · rounded-xl · rounded-2xl · rounded-pill
export default function Surfaces() {
return (
<>
<span className="rounded-xs">chip</span>
<input className="rounded-sm" />
<button className="rounded-md">Click</button>
<article className="rounded-lg">Card</article>
<section className="rounded-2xl">Hero</section>
<img className="rounded-pill" src="/avatar.png" />
</>
);
}1.4 Shadow & elevation
Four elevation steps, one pink emphasis, one focus ring. Shadows bias warm — they're all based on ink at low opacity, never pure black. Don't stack shadows; pick one token.
Elevation tokens
--sh-0 → --sh-pink.card { box-shadow: var(--sh-2); }
.card:hover { box-shadow: var(--sh-3); }
.modal { box-shadow: var(--sh-4); }
.button.cta { box-shadow: var(--sh-pink); }
:focus-visible { outline: 0; box-shadow: var(--sh-focus); }
// Shadows are CSS variables — reach for them directly via inline style
// or the kit's component classes (`.card`, `.button.cta`) imported from
// @magicblocksai/css.
export default function Card({ children }: { children: React.ReactNode }) {
return (
<article
className="rounded-lg p-s-7 transition-shadow"
style={{ boxShadow: "var(--sh-2)" }}
onMouseEnter={(e) => (e.currentTarget.style.boxShadow = "var(--sh-3)")}
onMouseLeave={(e) => (e.currentTarget.style.boxShadow = "var(--sh-2)")}
>
{children}
</article>
);
}1.5 Motion
Four durations. One ease. Motion should be confident, not chatty — it confirms what happened, not what's about to happen. Respect prefers-reduced-motion; the tokens below do.
Durations + easing
--dur-1 → --dur-4 · --ease--ease is a spring-like out-curve (0.2, 0.8, 0.2, 1) that feels alive without being bouncy. Use it for nearly every transition.
Hover the squares → each transitions at its own duration.
:root {
--dur-1: 100ms;
--dur-2: 160ms;
--dur-3: 240ms;
--dur-4: 400ms;
--ease: cubic-bezier(0.2, 0.8, 0.2, 1);
}
.button { transition: background var(--dur-2) var(--ease); }
.card { transition: box-shadow var(--dur-3) var(--ease),
transform var(--dur-3) var(--ease); }
.modal { animation: pop var(--dur-4) var(--ease); }
@media (prefers-reduced-motion: reduce) {
* { transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important; }
}
// Tailwind preset maps duration tokens to: duration-1/2/3/4 utilities.
// All components in @magicblocksai/ui already use these tokens internally,
// so prefers-reduced-motion is honoured automatically.
export default function InteractiveButton() {
return (
<button
className="transition-colors duration-2 ease-out
bg-accent text-paper rounded-md px-s-5 py-s-3"
>
Hover me
</button>
);
}1.6 Borders
Borders are almost always 1px, almost always var(--hair). A 2px border reads as a shout — reserve it for the one element that needs a shout.
Border styles
hair / hair-soft / accent.card { border: 1px solid var(--hair); }
.nested { border: 1px solid var(--hair-soft); }
.dropzone { border: 1px dashed var(--hair); }
.selected { border: 1px solid var(--accent);
background: var(--accent-soft); }
/* Inset border — no layout shift on hover */
.chip { box-shadow: inset 0 0 0 1px var(--hair); }
// Tailwind preset exposes hair / hair-soft as border colours.
export default function Surfaces() {
return (
<>
<article className="border border-hair rounded-lg">Card</article>
<div className="border border-hair-soft">Nested</div>
<div className="border border-dashed border-hair p-s-7">
Drop files here
</div>
<div className="border border-accent bg-accent-soft">Selected</div>
<span className="shadow-[inset_0_0_0_1px_var(--hair)]">Chip</span>
</>
);
}1.7 Opacity & emphasis
Text hierarchy is built with named foreground tokens, not raw opacity. The only time opacity-alone is appropriate is for disabled affordances.
Emphasis levels
fg / fg-soft / fg-dim / fg-faintEach step drops the reader's attention by a clear notch. Don't use more than three of these in the same block of text.
/* Text emphasis by token, not by raw opacity. */
.title { color: var(--fg); } /* primary */
.body { color: var(--fg-soft); } /* secondary */
.meta { color: var(--fg-dim); } /* metadata */
.caption { color: var(--fg-faint); } /* hints */
/* Disabled uses real opacity. */
.button[aria-disabled="true"] { opacity: .4; pointer-events: none; }
// Tailwind preset: text-fg · text-fg-soft · text-fg-dim · text-fg-faint.
export default function Article() {
return (
<article>
<h2 className="text-fg">Primary title</h2>
<p className="text-fg-soft">Body copy uses fg-soft.</p>
<span className="text-fg-dim text-sm">Updated 2h ago</span>
<span className="text-fg-faint text-xs">Optional hint</span>
<button aria-disabled="true" className="opacity-40 pointer-events-none">
Disabled
</button>
</article>
);
}1.8 Z-index
Five named layers, big gaps between them. Never write a naked z-index — if something needs to sit between two layers, the layer tokens are wrong, not the component.
Stacking tokens
--z-base → --z-toast:root {
--z-base: 1;
--z-sticky: 10;
--z-overlay: 100;
--z-modal: 200;
--z-toast: 300;
}
.topnav { position: sticky; z-index: var(--z-sticky); }
.tooltip { position: absolute; z-index: var(--z-overlay); }
.modal { position: fixed; z-index: var(--z-modal); }
.toast { position: fixed; z-index: var(--z-toast); }
// Reach for the z-index tokens directly via inline style. The kit's overlay,
// modal, and toast components from @magicblocksai/ui already use them.
export default function StickyTopnav() {
return (
<nav
className="sticky top-0 bg-paper border-b border-hair"
style={{ zIndex: "var(--z-sticky)" }}
>
…
</nav>
);
}1.9 How a token flows into a component
Every component in this library is assembled from the same short list of tokens. Here's the canonical example — the hero CTA button, broken out into the four aspects (colour, radius, spacing, motion) that every component in the library uses.
1.10 Brand mark
The MagicBlocks glyph and lockup, shipped from the kit (no consumer-side /public/brand assets needed). Self-coloured — the glyph renders the canonical four-colour spark (pink · yellow · green · blue) regardless of theme; the wordmark is the real magicblocks logotype and flips for ink backgrounds via variant="dark". Available since v1.61.0; glyph + wordmark corrected to the full brand artwork in v3.2.0.
Logomark + Logo
.logomark / .logoGlyph alone at three sizes, then the lockup on paper and ink backgrounds.
<!-- Glyph alone -->
<svg class="logomark" viewBox="61.6 495.46 184 184" width="28" height="28">
<!-- twelve polygons: the four-quadrant spark (pink/yellow/green/blue) -->
</svg>
<!-- Lockup -->
<span class="logo logo-md">
<svg class="logomark" .../>
<span class="logo-wordmark"><svg viewBox="260 500 1030 180" fill="currentColor"><!-- magicblocks logotype paths --></svg></span>
</span>
<!-- Lockup on ink background -->
<span class="logo logo-md logo-dark"> ... </span>
/* Ships in _shared.css — .logo / .logo-{sm,md,lg} / .logo-dark / .logo-wordmark / .logomark.
* The glyph fills are inline on the SVG (canonical brand palette); the
* wordmark colour flips with .logo-dark. */
// No JS — pure markup. The glyph is self-coloured; no theme-toggle wiring needed.
import { Logomark, Logo } from "@magicblocksai/ui";
<Logomark size={28} />
<Logo size="md" />
<Logo variant="dark" size="md" /> {/* on --ink */}