Themes that actually work
The index page shows @scope theme code alongside hardcoded Tailwind previews. The themes aren't doing anything. This page makes them real.
One shape's HTML never changes — only the theme wrapper changes — and you get genuinely different visual treatments. Not conceptually. With working CSS you can inspect.
interface UserProfile {
name: string
avatar: string
role: "admin" | "member" | "guest"
joinedAt: Date
} The 3 shapes
Each shape is a fixed HTML structure that reads from CSS custom properties. No class names on children — @scope targets elements by type and position. The HTML is immutable per shape.
<div data-theme="light">
<article data-shape="profile-horizontal">
<span data-primitive="avatar" data-size="md">AW</span>
<div>
<h3>Alan White</h3>
<span>Admin</span>
<time data-primitive="timestamp" data-size="sm">January 2024</time>
</div>
</article>
</div> Alan White
Admin<div data-theme="light">
<article data-shape="profile-vertical">
<span data-primitive="avatar" data-size="lg">AW</span>
<h3>Alan White</h3>
<span data-primitive="badge">Admin</span>
<time data-primitive="timestamp" data-size="md">Joined January 2024</time>
</article>
</div> Written by AW Alan White in the design channel.
<span data-shape="profile-inline">
<span data-primitive="avatar" data-size="sm">AW</span>
<span>Alan White</span>
</span> Theme anatomy
A theme is a data-theme attribute that sets colour tokens as CSS custom properties. Every theme sets the same property names to different values. Themes are purely colour — shapes own all spacing, padding, layout, and radii.
[data-theme="light"] {
--surface: #ffffff;
--on-surface: #0a0a0a;
--on-surface-muted: #525252;
--on-surface-faint: #a3a3a3;
--border: #e5e5e5;
--accent: #6366f1;
--avatar-bg: #e0e7ff;
--avatar-text: #4338ca;
--badge-bg: #f3f4f6;
--badge-text: #374151;
} [data-theme="dark"] {
--surface: #1e1e2e;
--on-surface: #e2e2e2;
--on-surface-muted: #a0a0b0;
--on-surface-faint: #606070;
--border: rgba(255,255,255,0.1);
--accent: #818cf8;
--avatar-bg: rgba(99,102,241,0.2);
--avatar-text: #a5b4fc;
--badge-bg: rgba(255,255,255,0.08);
--badge-text: #c0c0d0;
} @scope ([data-shape="profile-horizontal"]) {
:scope {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.875rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
}
/* avatar owns its own appearance via
@scope([data-primitive="avatar"]) */
h3 { color: var(--on-surface); }
span { color: var(--on-surface-muted); }
/* timestamp owns its own appearance via
@scope([data-primitive="timestamp"]) */
} Same shape, swap the wrapper
The Horizontal shape below is identical in all four cases. Only the data-theme attribute on the wrapper changes. Inspect the DOM to verify — zero class names on shape elements.
Alan White
AdminAlan White
AdminAlan White
AdminAlan White
AdminThe matrix — 3 shapes × 4 themes
3 shape definitions + 4 theme definitions = 12 distinct rendered outputs from 7 pieces of CSS. The traditional approach would require 12 bespoke component variants.
Every cell below is the same HTML per row. Only the wrapping data-theme attribute differs per column.
Alan White
AdminAlan White
AdminAlan White
AdminAlan White
AdminAlan White
AdminAlan White
AdminAlan White
AdminAlan White
AdminPost by AW Alan White
Post by AW Alan White
Post by AW Alan White
Post by AW Alan White
Per-instance allocation
A realistic scenario: multiple UserProfile instances on the same page, each with a contextually appropriate shape and theme. Same interface data. Same shapes. Different themes per instance based on where they appear.
Alan White
AdminSarah Kim
MemberMarcus Jones
MemberAlan White
AdminThis post was written by AW Alan White and explores the separation of interface, shape, and theme in modern component architecture.
Reviewed by SKSarah Kim and MJMarcus Jones.
What just happened: Every instance above renders from the same UserProfile interface. The shapes (Horizontal, Vertical, Inline) are @scope blocks targeting semantic HTML by element type. The themes are data-theme attributes that set custom properties. No component was duplicated. No variant prop was needed. No class names on children. 3 shapes + 4 themes = 12 possible outputs from 7 definitions.
Design tokens — the forward path
The custom properties our themes set today map 1:1 to the W3C Design Tokens Community Group (DTCG) format. This isn't a future aspiration — the token spec reached stable status (v2025.10) and is already supported by Style Dictionary, Figma Variables, and Penpot.
Every --custom-property in our themes is a design token waiting for a tokens.json file. The mapping is direct.
{
"surface": { "$type": "color", "$value": "#ffffff" },
"on-surface": { "$type": "color", "$value": "#0a0a0a" },
"on-surface-muted": { "$type": "color", "$value": "#525252" },
"border": { "$type": "color", "$value": "#e5e5e5" },
"accent": { "$type": "color", "$value": "#6366f1" },
"avatar-bg": { "$type": "color", "$value": "#e0e7ff" },
"avatar-text": { "$type": "color", "$value": "#4338ca" }
} [data-theme="light"] {
--surface: #ffffff;
--on-surface: #0a0a0a;
--on-surface-muted: #525252;
--border: #e5e5e5;
--accent: #6366f1;
--avatar-bg: #e0e7ff;
--avatar-text: #4338ca;
} What this means: When you adopt the Intershapes model — themes as custom properties, shapes as @scope blocks — you're already aligned with the design tokens spec. Adding tooling (Style Dictionary to generate CSS from JSON, Figma to export tokens) is additive, not a migration. The CSS you write today is the token format, just authored by hand instead of generated.