Skip to content

Interactive theme preview

A feature section that demonstrates the feature by re-theming itself.

MarketingadvancedFeaturedfeaturesinteractivethemetokens

Live preview

full widthLive preview — open it in a new tab for the full-height version.
Open the preview in a new tab

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.