Interactive theme preview
A feature section that demonstrates the feature by re-theming itself.
Live preview
Source
This exact file renders the preview above.
'use client'
import { useState } from 'react'
import { Container } from '@/components/ui/layout'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Card, CardDescription, CardTitle } from '@/components/ui/card'
import { cn } from '@/lib/cn'
import { densities, palettes, type Density, type Palette } from '@/lib/theme'
/**
* Interactive theme preview
*
* A feature section that demonstrates the feature. The preview frame carries
* its own `data-palette` and `data-density`, so the sample re-themes locally
* without changing the surrounding page — the same mechanism the catalogue
* preview frames use.
*/
export default function InteractivePreviewFeature() {
const [palette, setPalette] = useState<Palette>('editorial')
const [density, setDensity] = useState<Density>('default')
return (
<section className="border-b border-line bg-canvas py-section">
<Container>
<div className="max-w-2xl">
<h2 className="display-type text-2xl font-semibold text-ink-strong sm:text-3xl">
Change the theme. Watch the components follow.
</h2>
<p className="mt-3 text-md text-ink-muted">
Nothing below is re-implemented per palette. Every difference comes from tokens.
</p>
</div>
<div className="mt-10 grid gap-6 lg:grid-cols-[16rem_1fr]">
<div className="flex flex-col gap-5">
<div>
<h3 className="label-caps mb-2 text-ink-subtle">Palette</h3>
<div
role="radiogroup"
aria-label="Preview palette"
className="flex flex-wrap gap-1.5"
>
{palettes.map((option) => (
<button
key={option.id}
type="button"
role="radio"
aria-checked={palette === option.id}
onClick={() => setPalette(option.id)}
className={cn(
'rounded-full border px-3 py-1.5 text-xs font-medium transition-colors',
palette === option.id
? 'border-accent bg-accent-soft text-accent-soft-ink'
: 'border-line text-ink-muted hover:border-line-strong hover:text-ink',
)}
>
{option.label}
</button>
))}
</div>
</div>
<div>
<h3 className="label-caps mb-2 text-ink-subtle">Density</h3>
<div
role="radiogroup"
aria-label="Preview density"
className="flex flex-wrap gap-1.5"
>
{densities.map((option) => (
<button
key={option.id}
type="button"
role="radio"
aria-checked={density === option.id}
onClick={() => setDensity(option.id)}
className={cn(
'rounded-full border px-3 py-1.5 text-xs font-medium transition-colors',
density === option.id
? 'border-accent bg-accent-soft text-accent-soft-ink'
: 'border-line text-ink-muted hover:border-line-strong hover:text-ink',
)}
>
{option.label}
</button>
))}
</div>
</div>
</div>
<div
data-palette={palette}
data-density={density}
className="rounded-xl border border-line bg-canvas p-6 sm:p-8"
>
<div className="flex flex-wrap items-center gap-2">
<Badge tone="accent">{palettes.find((p) => p.id === palette)?.label}</Badge>
<Badge>{densities.find((d) => d.id === density)?.label}</Badge>
</div>
<div className="mt-5 grid gap-4 sm:grid-cols-2">
<Card tone="raised">
<CardTitle>Sample card</CardTitle>
<CardDescription className="mt-1">
Radius, padding and shadow are all token-driven.
</CardDescription>
<div className="mt-4 flex flex-wrap gap-2">
<Button size="sm">Primary</Button>
<Button size="sm" variant="outline">
Outline
</Button>
</div>
</Card>
<Card tone="accent">
<CardTitle>Accent card</CardTitle>
<CardDescription className="mt-1">
The soft accent trio re-tints with the palette.
</CardDescription>
<div className="mt-4 flex flex-wrap gap-2">
<Button size="sm" variant="soft">
Soft
</Button>
<Button size="sm" variant="ghost">
Ghost
</Button>
</div>
</Card>
</div>
</div>
</div>
</Container>
</section>
)
}
components/blocks/sections/features/interactive-preview.tsx
'use client'
import { useState } from 'react'
import { Container } from '@/components/ui/layout'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Card, CardDescription, CardTitle } from '@/components/ui/card'
import { cn } from '@/lib/cn'
import { densities, palettes, type Density, type Palette } from '@/lib/theme'
/**
* Interactive theme preview
*
* A feature section that demonstrates the feature. The preview frame carries
* its own `data-palette` and `data-density`, so the sample re-themes locally
* without changing the surrounding page — the same mechanism the catalogue
* preview frames use.
*/
export default function InteractivePreviewFeature() {
const [palette, setPalette] = useState<Palette>('editorial')
const [density, setDensity] = useState<Density>('default')
return (
<section className="border-b border-line bg-canvas py-section">
<Container>
<div className="max-w-2xl">
<h2 className="display-type text-2xl font-semibold text-ink-strong sm:text-3xl">
Change the theme. Watch the components follow.
</h2>
<p className="mt-3 text-md text-ink-muted">
Nothing below is re-implemented per palette. Every difference comes from tokens.
</p>
</div>
<div className="mt-10 grid gap-6 lg:grid-cols-[16rem_1fr]">
<div className="flex flex-col gap-5">
<div>
<h3 className="label-caps mb-2 text-ink-subtle">Palette</h3>
<div
role="radiogroup"
aria-label="Preview palette"
className="flex flex-wrap gap-1.5"
>
{palettes.map((option) => (
<button
key={option.id}
type="button"
role="radio"
aria-checked={palette === option.id}
onClick={() => setPalette(option.id)}
className={cn(
'rounded-full border px-3 py-1.5 text-xs font-medium transition-colors',
palette === option.id
? 'border-accent bg-accent-soft text-accent-soft-ink'
: 'border-line text-ink-muted hover:border-line-strong hover:text-ink',
)}
>
{option.label}
</button>
))}
</div>
</div>
<div>
<h3 className="label-caps mb-2 text-ink-subtle">Density</h3>
<div
role="radiogroup"
aria-label="Preview density"
className="flex flex-wrap gap-1.5"
>
{densities.map((option) => (
<button
key={option.id}
type="button"
role="radio"
aria-checked={density === option.id}
onClick={() => setDensity(option.id)}
className={cn(
'rounded-full border px-3 py-1.5 text-xs font-medium transition-colors',
density === option.id
? 'border-accent bg-accent-soft text-accent-soft-ink'
: 'border-line text-ink-muted hover:border-line-strong hover:text-ink',
)}
>
{option.label}
</button>
))}
</div>
</div>
</div>
<div
data-palette={palette}
data-density={density}
className="rounded-xl border border-line bg-canvas p-6 sm:p-8"
>
<div className="flex flex-wrap items-center gap-2">
<Badge tone="accent">{palettes.find((p) => p.id === palette)?.label}</Badge>
<Badge>{densities.find((d) => d.id === density)?.label}</Badge>
</div>
<div className="mt-5 grid gap-4 sm:grid-cols-2">
<Card tone="raised">
<CardTitle>Sample card</CardTitle>
<CardDescription className="mt-1">
Radius, padding and shadow are all token-driven.
</CardDescription>
<div className="mt-4 flex flex-wrap gap-2">
<Button size="sm">Primary</Button>
<Button size="sm" variant="outline">
Outline
</Button>
</div>
</Card>
<Card tone="accent">
<CardTitle>Accent card</CardTitle>
<CardDescription className="mt-1">
The soft accent trio re-tints with the palette.
</CardDescription>
<div className="mt-4 flex flex-wrap gap-2">
<Button size="sm" variant="soft">
Soft
</Button>
<Button size="sm" variant="ghost">
Ghost
</Button>
</div>
</Card>
</div>
</div>
</div>
</Container>
</section>
)
}
lib/theme.ts
/**
* Theme axes.
*
* Foundry separates three concerns that most systems conflate into one
* "theme" enum:
*
* scheme — light or dark, or follow the operating system
* palette — accent ramp, display typeface and corner treatment
* density — control height and layout rhythm
*
* They compose: Corporate + dark + compact is a valid, authored combination.
* All three are written to `<html>` as data attributes and read back by CSS.
*/
export const SCHEME_STORAGE_KEY = 'foundry.scheme'
export const PALETTE_STORAGE_KEY = 'foundry.palette'
export const DENSITY_STORAGE_KEY = 'foundry.density'
export type ColorScheme = 'light' | 'dark'
export type SchemePreference = ColorScheme | 'system'
export type Palette = 'editorial' | 'corporate' | 'minimal' | 'modern' | 'warm'
export type Density = 'compact' | 'default' | 'relaxed'
export const palettes: Array<{ id: Palette; label: string; description: string }> = [
{
id: 'editorial',
label: 'Editorial',
description: 'Serif display, tight radii, electric indigo. The Foundry default.',
},
{
id: 'corporate',
label: 'Corporate',
description: 'Grotesque display, cool greys, navy accent. Reads institutional.',
},
{
id: 'minimal',
label: 'Minimal',
description: 'Zero radius, no elevation, monochrome accent. Everything is a line.',
},
{
id: 'modern',
label: 'Modern',
description: 'Generous radii, tighter tracking, teal accent. Contemporary product.',
},
{
id: 'warm',
label: 'Warm',
description: 'Cream paper, terracotta accent, serif display. Hospitality and craft.',
},
]
export const densities: Array<{ id: Density; label: string; description: string }> = [
{ id: 'compact', label: 'Compact', description: 'Data-dense tools and admin surfaces.' },
{ id: 'default', label: 'Default', description: 'Balanced for documentation and product UI.' },
{ id: 'relaxed', label: 'Relaxed', description: 'Marketing pages and long-form reading.' },
]
export const defaultScheme: SchemePreference = 'system'
export const defaultPalette: Palette = 'editorial'
export const defaultDensity: Density = 'default'
export function isPalette(value: unknown): value is Palette {
return palettes.some((palette) => palette.id === value)
}
export function isDensity(value: unknown): value is Density {
return densities.some((density) => density.id === value)
}
export function isSchemePreference(value: unknown): value is SchemePreference {
return value === 'light' || value === 'dark' || value === 'system'
}
/**
* Blocking script injected into `<head>`.
*
* It runs before first paint, so the correct scheme, palette and density are
* on `<html>` by the time any pixels are drawn — no flash of the wrong theme,
* and no `suppressHydrationWarning` on the body.
*
* Written as a string rather than a module because it must execute inline,
* before the React bundle exists. Every read is wrapped: a browser with
* storage disabled falls back to the system scheme rather than throwing.
*/
export const themeInitScript = `(function(){try{
var d=document.documentElement;
var s=localStorage.getItem(${JSON.stringify(SCHEME_STORAGE_KEY)});
var p=localStorage.getItem(${JSON.stringify(PALETTE_STORAGE_KEY)});
var y=localStorage.getItem(${JSON.stringify(DENSITY_STORAGE_KEY)});
var m=window.matchMedia('(prefers-color-scheme: dark)').matches;
var scheme=(s==='light'||s==='dark')?s:(m?'dark':'light');
d.setAttribute('data-theme',scheme);
d.setAttribute('data-palette',${JSON.stringify(palettes.map((item) => item.id))}.indexOf(p)>-1?p:${JSON.stringify(defaultPalette)});
d.setAttribute('data-density',(y==='compact'||y==='default'||y==='relaxed')?y:${JSON.stringify(defaultDensity)});
d.style.colorScheme=scheme;
d.setAttribute('data-js','on');
}catch(e){
document.documentElement.setAttribute('data-js','on');
document.documentElement.setAttribute('data-theme','light');
document.documentElement.setAttribute('data-palette',${JSON.stringify(defaultPalette)});
document.documentElement.setAttribute('data-density',${JSON.stringify(defaultDensity)});
}})();`
components/ui/card.tsx
import type { HTMLAttributes, ReactNode, ElementType } from 'react'
import { cn } from '@/lib/cn'
/**
* Card & Panel
*
* Two containment primitives with deliberately different jobs:
*
* Card — a discrete, often interactive record in a collection.
* Panel — a titled region of a page, with an optional header action row.
*
* Keeping them separate is what stops the library from degenerating into
* "everything is a rounded box with a shadow".
*/
export interface CardProps extends HTMLAttributes<HTMLDivElement> {
as?: ElementType
/** Forwarded when `as` renders a link. */
href?: string
/** `outline` is the default; `raised` adds elevation; `sunken` insets. */
tone?: 'outline' | 'raised' | 'sunken' | 'ghost' | 'accent'
/** Adds hover affordance. Only use when the whole card is a link/button. */
interactive?: boolean
padding?: 'none' | 'sm' | 'md' | 'lg'
}
const cardTones = {
outline: 'bg-surface border border-line',
raised: 'bg-surface-raised border border-line shadow-sm',
sunken: 'bg-surface-sunken border border-line-subtle',
ghost: 'bg-transparent border border-transparent',
accent: 'bg-accent-soft border border-accent-line',
} as const
const cardPadding = {
none: 'p-0',
sm: 'p-3',
md: 'p-card',
lg: 'p-6 sm:p-8',
} as const
export function Card({
as: Tag = 'div',
tone = 'outline',
interactive = false,
padding = 'md',
className,
children,
...props
}: CardProps) {
return (
<Tag
className={cn(
'rounded-lg',
cardTones[tone],
cardPadding[padding],
interactive &&
'transition-[border-color,box-shadow,background-color] duration-150 ease-standard hover:border-line-strong hover:shadow-sm',
className,
)}
{...props}
>
{children}
</Tag>
)
}
export function CardHeader({ className, children, ...props }: HTMLAttributes<HTMLDivElement>) {
return (
<div className={cn('flex items-start justify-between gap-4', className)} {...props}>
{children}
</div>
)
}
export function CardTitle({
as: Tag = 'h3',
className,
children,
...props
}: HTMLAttributes<HTMLHeadingElement> & { as?: ElementType }) {
return (
<Tag className={cn('text-md leading-snug font-semibold text-ink-strong', className)} {...props}>
{children}
</Tag>
)
}
export function CardDescription({
className,
children,
...props
}: HTMLAttributes<HTMLParagraphElement>) {
return (
<p className={cn('text-sm leading-normal text-ink-muted', className)} {...props}>
{children}
</p>
)
}
export function CardFooter({ className, children, ...props }: HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn(
'mt-4 flex flex-wrap items-center gap-3 border-t border-line-subtle pt-4',
className,
)}
{...props}
>
{children}
</div>
)
}
export interface PanelProps extends Omit<HTMLAttributes<HTMLElement>, 'title'> {
title: ReactNode
description?: ReactNode
/** Rendered on the right of the panel header. */
action?: ReactNode
/** Removes body padding — for tables and lists that manage their own. */
flush?: boolean
as?: ElementType
headingLevel?: 'h2' | 'h3' | 'h4'
}
export function Panel({
title,
description,
action,
flush = false,
as: Tag = 'section',
headingLevel: Heading = 'h3',
className,
children,
...props
}: PanelProps) {
return (
<Tag
className={cn('overflow-hidden rounded-lg border border-line bg-surface', className)}
{...props}
>
<div className="flex flex-wrap items-start justify-between gap-3 border-b border-line-subtle bg-surface-sunken px-4 py-3">
<div className="min-w-0">
<Heading className="text-sm font-semibold text-ink-strong">{title}</Heading>
{description ? <p className="mt-0.5 text-xs text-ink-muted">{description}</p> : null}
</div>
{action ? <div className="flex shrink-0 items-center gap-2">{action}</div> : null}
</div>
<div className={cn(flush ? '' : 'p-card')}>{children}</div>
</Tag>
)
}
Demo source — adapt to your project. Foundry is not published as a package.
Usage
The preview frame carries its own data-palette and data-density, so the sample re-themes locally without changing the surrounding page — the same mechanism the catalogue previews use.
- Scope theme attributes to the frame, never to the document, or the page fights the section.
- Show at least two components, so the change is visibly systemic rather than a colour swap.
Variants and states
Every entry below is a genuine difference in behaviour or layout, and every one of them is visible in the preview above.
- Palette radio group
- Density radio group
- Locally scoped preview
Accessibility
- Radio groups
- Both controls are radiogroups with aria-checked, so the current choice is announced.
- Local scope
- Theme attributes are set on a wrapper element, not on the document root.
Foundry implements published ARIA patterns and is tested against them. No WCAG certification is claimed — see the accessibility documentation for what is and is not covered.
Related
All sectionsSingle-feature spotlight
One capability given the room of a whole section, with a working demonstration.
intermediate3 variantsDocumentation shell
Header, search and a collapsible section tree that becomes an in-flow outline on mobile.
intermediateFeatured3 variantsCard
A discrete record in a collection, with five tones and a composable header, title, description and footer.
starterFeatured6 variants