UIPackage

Theme Customize

block dashboard
Edit on GitHub

Compact theme customization popover. Light/Dark/System mode (via next-themes), six curated color presets that overwrite `--primary` / `--primary-foreground` / `--ring`, and a radius slider bound to `--radius`. Persists to localStorage and exposes a one-click Copy CSS for the active token set.

Also available for Vue ->

Installation

$ npx shadcn@latest add https://react.uipkge.dev/r/react/theme-customize.json

Or with the named registry: npx shadcn@latest add @uipkge-react/theme-customize

Examples

Schema

Type aliases exported from this item's source. Use these to shape the data you pass in.

Preset
interface Preset {
  key: PresetKey
  label: string
  swatch: string
  light: { primary: string; primaryForeground: string; ring: string }
  dark: { primary: string; primaryForeground: string; ring: string }
}
ThemePreset
interface ThemePreset {
  key: ThemeKey
  label: string
  emoji: string
  category: CategoryKey
  mode: Mode
  color: PresetKey
  tint: TintKey
  font: FontKey
  radius: number
}

Files (1)

  • components/blocks/ThemeCustomize.tsx 33.1 kB
    'use client'
    
    import * as React from 'react'
    import { useTheme } from 'next-themes'
    import { Check, Copy, Download, Monitor, Moon, Palette, Pipette, RotateCcw, Shuffle, Sun, type LucideIcon } from 'lucide-react'
    import { Button } from '@/components/ui/button'
    import { Slider } from '@/components/ui/slider'
    import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet'
    
    type Mode = 'light' | 'dark' | 'system'
    type PresetKey =
      | 'neutral'
      | 'zinc'
      | 'slate'
      | 'stone'
      | 'rose'
      | 'red'
      | 'orange'
      | 'amber'
      | 'yellow'
      | 'lime'
      | 'emerald'
      | 'teal'
      | 'cyan'
      | 'sky'
      | 'blue'
      | 'indigo'
      | 'violet'
      | 'purple'
      | 'fuchsia'
      | 'pink'
      | 'custom'
    type ThemeKey =
      | 'newyear'
      | 'sunset'
      | 'cyberpunk'
      | 'forest'
      | 'ocean'
      | 'pastel'
      | 'mono'
      | 'brutalist'
      | 'notebook'
      | 'caffeine'
      | 'midnight'
      | 'mocha'
      | 'retro'
      | 'tangerine'
      | 'quartz'
      | 'cosmic'
      | 'sage'
      | 'marble'
      | 'ember'
      | 'amethyst'
      | 'rainfall'
      | 'peach'
      | 'tropical'
      | 'vintage'
    type FontKey = 'sans' | 'serif' | 'mono'
    type TintKey = 'pure' | 'cool' | 'warm'
    
    interface Preset {
      key: PresetKey
      label: string
      swatch: string
      light: { primary: string; primaryForeground: string; ring: string }
      dark: { primary: string; primaryForeground: string; ring: string }
    }
    
    function mkPreset(key: PresetKey, label: string, hue: number, l = 0.6, c = 0.2, fg = 'oklch(0.985 0 0)'): Preset {
      const swatch = `oklch(${l} ${c} ${hue})`
      return {
        key,
        label,
        swatch,
        light: { primary: swatch, primaryForeground: fg, ring: swatch },
        dark: { primary: swatch, primaryForeground: fg, ring: swatch },
      }
    }
    
    const PRESETS: Preset[] = [
      // Greyscale family
      {
        key: 'neutral',
        label: 'Neutral',
        swatch: 'oklch(0.205 0 0)',
        light: { primary: 'oklch(0.205 0 0)', primaryForeground: 'oklch(0.985 0 0)', ring: 'oklch(0.708 0 0)' },
        dark: { primary: 'oklch(0.922 0 0)', primaryForeground: 'oklch(0.205 0 0)', ring: 'oklch(0.556 0 0)' },
      },
      {
        key: 'zinc',
        label: 'Zinc',
        swatch: 'oklch(0.21 0.006 285.885)',
        light: {
          primary: 'oklch(0.21 0.006 285.885)',
          primaryForeground: 'oklch(0.985 0 0)',
          ring: 'oklch(0.705 0.015 286.067)',
        },
        dark: {
          primary: 'oklch(0.92 0.004 286.32)',
          primaryForeground: 'oklch(0.21 0.006 285.885)',
          ring: 'oklch(0.552 0.016 285.938)',
        },
      },
      {
        key: 'slate',
        label: 'Slate',
        swatch: 'oklch(0.21 0.034 264.665)',
        light: {
          primary: 'oklch(0.21 0.034 264.665)',
          primaryForeground: 'oklch(0.985 0 0)',
          ring: 'oklch(0.554 0.046 257.417)',
        },
        dark: {
          primary: 'oklch(0.929 0.013 255.508)',
          primaryForeground: 'oklch(0.21 0.034 264.665)',
          ring: 'oklch(0.554 0.046 257.417)',
        },
      },
      {
        key: 'stone',
        label: 'Stone',
        swatch: 'oklch(0.216 0.006 56.043)',
        light: {
          primary: 'oklch(0.216 0.006 56.043)',
          primaryForeground: 'oklch(0.985 0.001 106.423)',
          ring: 'oklch(0.553 0.013 58.071)',
        },
        dark: {
          primary: 'oklch(0.923 0.003 48.717)',
          primaryForeground: 'oklch(0.216 0.006 56.043)',
          ring: 'oklch(0.553 0.013 58.071)',
        },
      },
      // Warm
      mkPreset('rose', 'Rose', 16.439, 0.645, 0.246),
      mkPreset('red', 'Red', 27.325, 0.637, 0.237),
      mkPreset('orange', 'Orange', 47.604, 0.705, 0.213),
      mkPreset('amber', 'Amber', 70.08, 0.769, 0.188),
      mkPreset('yellow', 'Yellow', 95.277, 0.795, 0.184, 'oklch(0.286 0.066 53.813)'),
      // Greens
      mkPreset('lime', 'Lime', 130.85, 0.768, 0.233, 'oklch(0.274 0.072 132.109)'),
      mkPreset('emerald', 'Emerald', 162.48, 0.696, 0.17),
      mkPreset('teal', 'Teal', 184.704, 0.704, 0.14, 'oklch(0.984 0.014 180.72)'),
      // Cool
      mkPreset('cyan', 'Cyan', 221.723, 0.715, 0.143, 'oklch(0.302 0.056 229.695)'),
      mkPreset('sky', 'Sky', 235.711, 0.685, 0.169),
      mkPreset('blue', 'Blue', 264.376, 0.546, 0.245),
      mkPreset('indigo', 'Indigo', 277.117, 0.511, 0.262),
      // Purples
      mkPreset('violet', 'Violet', 293.009, 0.541, 0.281),
      mkPreset('purple', 'Purple', 303.9, 0.558, 0.288),
      mkPreset('fuchsia', 'Fuchsia', 322.16, 0.667, 0.295),
      mkPreset('pink', 'Pink', 354.308, 0.656, 0.241),
    ]
    
    type CategoryKey = 'featured' | 'editorial' | 'bold' | 'cool' | 'soft'
    
    interface ThemePreset {
      key: ThemeKey
      label: string
      emoji: string
      category: CategoryKey
      mode: Mode
      color: PresetKey
      tint: TintKey
      font: FontKey
      radius: number
    }
    
    const CATEGORY_LABELS: Record<CategoryKey, string> = {
      featured: 'Featured',
      editorial: 'Editorial',
      bold: 'Bold',
      cool: 'Cool',
      soft: 'Soft',
    }
    
    const THEMES: ThemePreset[] = [
      { key: 'newyear', label: 'New Year', emoji: '', category: 'featured', mode: 'dark', color: 'amber', tint: 'warm', font: 'serif', radius: 0.75 },
      { key: 'sunset', label: 'Sunset', emoji: '🌅', category: 'featured', mode: 'light', color: 'orange', tint: 'warm', font: 'sans', radius: 0.625 },
      { key: 'cyberpunk', label: 'Cyberpunk', emoji: '', category: 'featured', mode: 'dark', color: 'fuchsia', tint: 'cool', font: 'mono', radius: 0.125 },
      { key: 'forest', label: 'Forest', emoji: '🌿', category: 'featured', mode: 'light', color: 'emerald', tint: 'warm', font: 'serif', radius: 0.5 },
      { key: 'ocean', label: 'Ocean', emoji: '🌊', category: 'featured', mode: 'dark', color: 'cyan', tint: 'cool', font: 'sans', radius: 0.5 },
      { key: 'pastel', label: 'Pastel', emoji: '🌸', category: 'featured', mode: 'light', color: 'rose', tint: 'pure', font: 'sans', radius: 0.875 },
      { key: 'mono', label: 'Mono', emoji: '', category: 'editorial', mode: 'light', color: 'neutral', tint: 'pure', font: 'mono', radius: 0 },
      { key: 'brutalist', label: 'Brutalist', emoji: '', category: 'editorial', mode: 'light', color: 'neutral', tint: 'pure', font: 'mono', radius: 0 },
      { key: 'notebook', label: 'Notebook', emoji: '📓', category: 'editorial', mode: 'light', color: 'stone', tint: 'warm', font: 'serif', radius: 0.25 },
      { key: 'vintage', label: 'Vintage', emoji: '📜', category: 'editorial', mode: 'light', color: 'amber', tint: 'warm', font: 'serif', radius: 0.375 },
      { key: 'caffeine', label: 'Caffeine', emoji: '', category: 'editorial', mode: 'light', color: 'orange', tint: 'warm', font: 'serif', radius: 0.5 },
      { key: 'marble', label: 'Marble', emoji: '🏛', category: 'editorial', mode: 'light', color: 'stone', tint: 'pure', font: 'serif', radius: 0.25 },
      { key: 'retro', label: 'Retro', emoji: '🎮', category: 'bold', mode: 'dark', color: 'lime', tint: 'cool', font: 'mono', radius: 0 },
      { key: 'tangerine', label: 'Tangerine', emoji: '🍊', category: 'bold', mode: 'light', color: 'orange', tint: 'pure', font: 'sans', radius: 0.75 },
      { key: 'ember', label: 'Ember', emoji: '🔥', category: 'bold', mode: 'dark', color: 'red', tint: 'warm', font: 'sans', radius: 0.375 },
      { key: 'tropical', label: 'Tropical', emoji: '🌴', category: 'bold', mode: 'light', color: 'teal', tint: 'cool', font: 'sans', radius: 0.625 },
      { key: 'midnight', label: 'Midnight', emoji: '🌃', category: 'cool', mode: 'dark', color: 'indigo', tint: 'cool', font: 'sans', radius: 0.5 },
      { key: 'cosmic', label: 'Cosmic', emoji: '🌌', category: 'cool', mode: 'dark', color: 'violet', tint: 'cool', font: 'sans', radius: 0.625 },
      { key: 'rainfall', label: 'Rainfall', emoji: '🌧', category: 'cool', mode: 'dark', color: 'sky', tint: 'cool', font: 'sans', radius: 0.5 },
      { key: 'amethyst', label: 'Amethyst', emoji: '💜', category: 'cool', mode: 'dark', color: 'purple', tint: 'cool', font: 'sans', radius: 0.625 },
      { key: 'sage', label: 'Sage', emoji: '🌱', category: 'soft', mode: 'light', color: 'emerald', tint: 'warm', font: 'serif', radius: 0.75 },
      { key: 'peach', label: 'Peach', emoji: '🍑', category: 'soft', mode: 'light', color: 'amber', tint: 'warm', font: 'sans', radius: 0.875 },
      { key: 'quartz', label: 'Quartz', emoji: '💎', category: 'soft', mode: 'light', color: 'violet', tint: 'cool', font: 'sans', radius: 0.875 },
      { key: 'mocha', label: 'Mocha', emoji: '🌹', category: 'soft', mode: 'light', color: 'rose', tint: 'warm', font: 'serif', radius: 0.5 },
    ]
    
    const FONTS: { key: FontKey; label: string; stack: string }[] = [
      { key: 'sans', label: 'Sans', stack: '"DM Sans", ui-sans-serif, system-ui, sans-serif' },
      { key: 'serif', label: 'Serif', stack: '"Source Serif Pro", ui-serif, Georgia, serif' },
      { key: 'mono', label: 'Mono', stack: '"JetBrains Mono", ui-monospace, monospace' },
    ]
    
    const TINTS: { key: TintKey; label: string; light: string; dark: string }[] = [
      { key: 'pure', label: 'Pure', light: 'oklch(1 0 0)', dark: 'oklch(0.145 0 0)' },
      { key: 'cool', label: 'Cool', light: 'oklch(0.99 0.005 240)', dark: 'oklch(0.16 0.008 250)' },
      { key: 'warm', label: 'Warm', light: 'oklch(0.99 0.005 70)', dark: 'oklch(0.16 0.008 60)' },
    ]
    
    const STORAGE_KEY = 'uipkge-theme-customize'
    
    const modes: { value: Mode; label: string; icon: LucideIcon }[] = [
      { value: 'light', label: 'Light', icon: Sun },
      { value: 'dark', label: 'Dark', icon: Moon },
      { value: 'system', label: 'System', icon: Monitor },
    ]
    
    function isDarkActive(): boolean {
      if (typeof window === 'undefined') return false
      return document.documentElement.classList.contains('dark')
    }
    
    export function ThemeCustomize() {
      const { theme, setTheme } = useTheme()
      const mode = (theme as Mode) ?? 'system'
    
      const [preset, setPreset] = React.useState<PresetKey>('neutral')
      const [customColor, setCustomColor] = React.useState<string>('#ec4899')
      const [font, setFont] = React.useState<FontKey>('sans')
      const [tint, setTint] = React.useState<TintKey>('pure')
      const [radius, setRadius] = React.useState(0.3)
      const [copied, setCopied] = React.useState(false)
      const [exported, setExported] = React.useState(false)
      const [themeSearch, setThemeSearch] = React.useState('')
      const [sheetOpen, setSheetOpen] = React.useState(false)
    
      const activeTheme = React.useMemo<ThemePreset | null>(() => {
        // Radius isn't part of the preset signature any more (it stays
        // user-controlled), so the match only checks the four token axes.
        return (
          THEMES.find((t) => t.mode === mode && t.color === preset && t.tint === tint && t.font === font) ?? null
        )
      }, [mode, preset, tint, font])
    
      const filteredThemes = React.useMemo(() => {
        const q = themeSearch.trim().toLowerCase()
        if (!q) return THEMES
        return THEMES.filter((t) => t.label.toLowerCase().includes(q) || t.key.includes(q))
      }, [themeSearch])
    
      const themesByCategory = React.useMemo<{ key: CategoryKey; label: string; items: ThemePreset[] }[]>(() => {
        const order: CategoryKey[] = ['featured', 'editorial', 'bold', 'cool', 'soft']
        return order
          .map((c) => ({
            key: c,
            label: CATEGORY_LABELS[c],
            items: filteredThemes.filter((t) => t.category === c),
          }))
          .filter((g) => g.items.length > 0)
      }, [filteredThemes])
    
      const applyPreset = React.useCallback(
        (key: PresetKey) => {
          if (typeof window === 'undefined') return
          const root = document.documentElement
          if (key === 'custom') {
            root.style.setProperty('--primary', customColor)
            root.style.setProperty('--primary-foreground', isDarkActive() ? 'oklch(0.985 0 0)' : 'oklch(0.985 0 0)')
            root.style.setProperty('--ring', customColor)
            return
          }
          const found = PRESETS.find((p) => p.key === key)
          if (!found) return
          const tokens = isDarkActive() ? found.dark : found.light
          root.style.setProperty('--primary', tokens.primary)
          root.style.setProperty('--primary-foreground', tokens.primaryForeground)
          root.style.setProperty('--ring', tokens.ring)
        },
        [customColor],
      )
    
      function applyTint(key: TintKey) {
        if (typeof window === 'undefined') return
        const t = TINTS.find((x) => x.key === key)
        if (!t) return
        document.documentElement.style.setProperty('--background', isDarkActive() ? t.dark : t.light)
      }
    
      function applyFont(key: FontKey) {
        if (typeof window === 'undefined') return
        const f = FONTS.find((x) => x.key === key)
        if (!f) return
        document.documentElement.style.setProperty('--font-sans', f.stack)
      }
    
      function applyRadius(rem: number) {
        if (typeof window === 'undefined') return
        document.documentElement.style.setProperty('--radius', `${rem}rem`)
      }
    
      const save = React.useCallback(() => {
        try {
          localStorage.setItem(
            STORAGE_KEY,
            JSON.stringify({ preset, customColor, font, tint, radius }),
          )
        } catch {
          // ignore
        }
      }, [preset, customColor, font, tint, radius])
    
      function applyTheme(t: ThemePreset) {
        // Themes drive mode / color / tint / font. Radius is treated as a
        // user-controlled value (default 0.3) and is not changed by presets.
        setTheme(t.mode)
        setPreset(t.color)
        setTint(t.tint)
        setFont(t.font)
      }
    
      function applyRandomTheme() {
        const candidates = THEMES.filter((t) => t.key !== activeTheme?.key)
        const pick = candidates[Math.floor(Math.random() * candidates.length)]
        if (pick) applyTheme(pick)
      }
    
      function reset() {
        setPreset('neutral')
        setCustomColor('#ec4899')
        setFont('sans')
        setTint('pure')
        setRadius(0.3)
        const root = document.documentElement
        ;['--primary', '--primary-foreground', '--ring', '--background', '--font-sans', '--radius'].forEach((p) =>
          root.style.removeProperty(p),
        )
        try {
          localStorage.removeItem(STORAGE_KEY)
        } catch {
          // ignore
        }
      }
    
      function buildCss(): string {
        const found = PRESETS.find((p) => p.key === preset) ?? PRESETS[0]
        const isCustom = preset === 'custom'
        const tokens = isDarkActive() ? found.dark : found.light
        const t = TINTS.find((x) => x.key === tint) ?? TINTS[0]
        const f = FONTS.find((x) => x.key === font) ?? FONTS[0]
        const primary = isCustom ? customColor : tokens.primary
        const primaryFg = isCustom ? 'oklch(0.985 0 0)' : tokens.primaryForeground
        const ring = isCustom ? customColor : tokens.ring
        return `:root {
      --background: ${isDarkActive() ? t.dark : t.light};
      --primary: ${primary};
      --primary-foreground: ${primaryFg};
      --ring: ${ring};
      --radius: ${radius}rem;
      --font-sans: ${f.stack};
    }`
      }
    
      async function copyCss() {
        try {
          await navigator.clipboard.writeText(buildCss())
          setCopied(true)
          setTimeout(() => setCopied(false), 1600)
        } catch {
          // ignore
        }
      }
    
      function exportJson() {
        const blob = new Blob(
          [JSON.stringify({ preset, customColor, font, tint, radius, css: buildCss() }, null, 2)],
          { type: 'application/json' },
        )
        const url = URL.createObjectURL(blob)
        const a = document.createElement('a')
        a.href = url
        a.download = 'theme.json'
        a.click()
        URL.revokeObjectURL(url)
        setExported(true)
        setTimeout(() => setExported(false), 1600)
      }
    
      // Smoothly fade color changes on the whole document. Toggled briefly
      // during apply* so users don't see a hard snap between themes.
      function pulseTransition() {
        if (typeof document === 'undefined') return
        const root = document.documentElement
        root.style.transition = 'background-color 240ms ease, color 240ms ease'
        window.setTimeout(() => {
          root.style.transition = ''
        }, 300)
      }
    
      // Load persisted state + apply on mount; wire the Cmd/Ctrl+J shortcut.
      React.useEffect(() => {
        try {
          const raw = localStorage.getItem(STORAGE_KEY)
          if (raw) {
            const parsed = JSON.parse(raw)
            if (parsed.preset) setPreset(parsed.preset)
            if (parsed.customColor) setCustomColor(parsed.customColor)
            if (parsed.font) setFont(parsed.font)
            if (parsed.tint) setTint(parsed.tint)
            if (typeof parsed.radius === 'number') setRadius(parsed.radius)
          }
        } catch {
          // ignore
        }
    
        function onKeydown(e: KeyboardEvent) {
          // Cmd/Ctrl + J -> toggle the customizer
          if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'j') {
            e.preventDefault()
            setSheetOpen((v) => !v)
          }
        }
        if (typeof window !== 'undefined') window.addEventListener('keydown', onKeydown)
        return () => {
          if (typeof window !== 'undefined') window.removeEventListener('keydown', onKeydown)
        }
        // eslint-disable-next-line react-hooks/exhaustive-deps
      }, [])
    
      // watch([preset, customColor]) -> applyPreset + save
      React.useEffect(() => {
        applyPreset(preset)
        save()
        // eslint-disable-next-line react-hooks/exhaustive-deps
      }, [preset, customColor])
    
      // watch(font) -> applyFont + save
      React.useEffect(() => {
        applyFont(font)
        save()
        // eslint-disable-next-line react-hooks/exhaustive-deps
      }, [font])
    
      // watch(tint) -> applyTint + save
      React.useEffect(() => {
        applyTint(tint)
        save()
        // eslint-disable-next-line react-hooks/exhaustive-deps
      }, [tint])
    
      // watch(radius) -> applyRadius + save
      React.useEffect(() => {
        applyRadius(radius)
        save()
        // eslint-disable-next-line react-hooks/exhaustive-deps
      }, [radius])
    
      // watch(theme) -> re-apply preset + tint on next tick
      React.useEffect(() => {
        setTimeout(() => {
          applyPreset(preset)
          applyTint(tint)
        }, 0)
        // eslint-disable-next-line react-hooks/exhaustive-deps
      }, [theme])
    
      // watch([preset, customColor, font, tint, radius, theme]) -> pulseTransition
      React.useEffect(() => {
        pulseTransition()
        // eslint-disable-next-line react-hooks/exhaustive-deps
      }, [preset, customColor, font, tint, radius, theme])
    
      return (
        <Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
          <SheetTrigger asChild>
            <Button variant="outline" size="sm" className="gap-2">
              <Palette className="size-4" />
              <span className="hidden sm:inline">Customize</span>
              <kbd className="bg-muted text-muted-foreground hidden rounded px-1 py-0.5 text-[10px] font-medium sm:inline-block">
                ⌘J
              </kbd>
            </Button>
          </SheetTrigger>
          <SheetContent side="right" className="flex w-full flex-col gap-0 p-0 sm:max-w-md">
            <SheetHeader className="border-b px-6 py-4">
              <div className="flex items-center justify-between gap-3">
                <SheetTitle className="flex items-center gap-2 text-base">
                  {' '}
                  <Palette className="size-4" /> Customize{' '}
                </SheetTitle>
                {/* Active theme chip */}
                {activeTheme ? (
                  <span className="bg-muted text-foreground inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium">
                    <span className="text-[13px] leading-none">{activeTheme.emoji}</span>
                    {activeTheme.label}
                  </span>
                ) : (
                  <span className="text-muted-foreground bg-muted/50 rounded-full px-2 py-0.5 text-[11px] font-medium">
                    Custom
                  </span>
                )}
              </div>
              <SheetDescription className="text-xs">
                Live-edit tokens. Changes write to <code className="font-mono">:root</code> and persist locally.
              </SheetDescription>
              {/* Quick actions */}
              <div className="mt-2 flex gap-2">
                <Button variant="outline" size="xs" className="gap-1.5" onClick={applyRandomTheme}>
                  <Shuffle className="size-3" /> Surprise me
                </Button>
              </div>
            </SheetHeader>
            <div className="flex-1 overflow-y-auto px-6 py-5">
              <div className="space-y-6">
                {/* Curated themes */}
                <div>
                  <div className="mb-2 flex items-baseline justify-between">
                    <span className="text-muted-foreground text-xs font-medium tracking-wide uppercase">Themes</span>
                    <span className="text-muted-foreground text-[10px]">
                      {filteredThemes.length}
                      {themeSearch && <span> / {THEMES.length}</span>} presets
                    </span>
                  </div>
    
                  {/* Search */}
                  <div className="relative mb-3">
                    <input
                      value={themeSearch}
                      onChange={(e) => setThemeSearch(e.target.value)}
                      type="search"
                      placeholder="Search themes..."
                      className="border-input bg-background focus-visible:ring-ring h-8 w-full rounded-md border pr-2 pl-7 text-xs outline-none focus-visible:ring-2"
                    />
                    <svg
                      viewBox="0 0 24 24"
                      className="text-muted-foreground absolute top-1/2 left-2 size-3.5 -translate-y-1/2"
                      fill="none"
                      stroke="currentColor"
                      strokeWidth="2"
                      strokeLinecap="round"
                      strokeLinejoin="round"
                      aria-hidden="true"
                    >
                      <circle cx="11" cy="11" r="7" />
                      <path d="m21 21-4.3-4.3" />
                    </svg>
                  </div>
    
                  {/* Categorized groups */}
                  <div className="space-y-4">
                    {themesByCategory.map((group) => (
                      <div key={group.key}>
                        <div className="text-muted-foreground/70 mb-1.5 text-[10px] font-medium tracking-wider uppercase">
                          {group.label}
                        </div>
                        <div className="grid grid-cols-3 gap-2">
                          {group.items.map((t) => (
                            <button
                              key={t.key}
                              type="button"
                              title={`${t.mode} · ${t.color} · ${t.font} · r=${t.radius}`}
                              aria-pressed={activeTheme?.key === t.key}
                              className={[
                                'group relative flex flex-col overflow-hidden rounded-lg border text-left transition-all',
                                activeTheme?.key === t.key
                                  ? 'ring-ring ring-offset-background border-transparent ring-2 ring-offset-2'
                                  : 'hover:scale-[1.03] hover:shadow-md',
                              ].join(' ')}
                              onClick={() => applyTheme(t)}
                            >
                              {/* Mini-preview window */}
                              <div
                                className="relative h-14 px-2 py-1.5"
                                style={{
                                  background: (TINTS.find((x) => x.key === t.tint) || ({} as Record<string, string>))[
                                    t.mode === 'dark' ? 'dark' : 'light'
                                  ],
                                  color: t.mode === 'dark' ? 'oklch(0.985 0 0)' : 'oklch(0.205 0 0)',
                                }}
                              >
                                {/* Top row: dot + brand emoji */}
                                <div className="flex items-center justify-between">
                                  <div className="flex gap-0.5">
                                    <span className="size-1.5 rounded-full opacity-40" style={{ background: 'currentColor' }} />
                                    <span className="size-1.5 rounded-full opacity-25" style={{ background: 'currentColor' }} />
                                    <span className="size-1.5 rounded-full opacity-15" style={{ background: 'currentColor' }} />
                                  </div>
                                  <span className="text-[10px] leading-none">{t.emoji}</span>
                                </div>
                                {/* Mini button + chip preview */}
                                <div className="mt-2 flex items-center gap-1">
                                  <span
                                    className="inline-block h-3 px-1.5 text-[8px] leading-3 font-semibold"
                                    style={{
                                      background: PRESETS.find((p) => p.key === t.color)?.swatch,
                                      color: 'oklch(0.985 0 0)',
                                      borderRadius: `${Math.min(t.radius, 0.5)}rem`,
                                      fontFamily: FONTS.find((f) => f.key === t.font)?.stack,
                                    }}
                                  >
                                    Aa
                                  </span>
                                  <span className="block h-1 flex-1 rounded-full opacity-30" style={{ background: 'currentColor' }} />
                                </div>
                                {/* Lower text bar */}
                                <div className="mt-1.5 flex gap-1">
                                  <span className="block h-0.5 w-3 rounded-full opacity-20" style={{ background: 'currentColor' }} />
                                  <span className="block h-0.5 w-5 rounded-full opacity-15" style={{ background: 'currentColor' }} />
                                </div>
                              </div>
                              {/* Label strip */}
                              <div className="bg-muted/40 flex items-center justify-between px-2 py-1">
                                <span className="text-[10px] leading-none font-medium">{t.label}</span>
                                {activeTheme?.key === t.key && <Check className="text-primary size-3" />}
                              </div>
                            </button>
                          ))}
                        </div>
                      </div>
                    ))}
                    {filteredThemes.length === 0 && (
                      <div className="text-muted-foreground py-6 text-center text-xs">
                        No themes match "<span className="font-mono">{themeSearch}</span>"
                      </div>
                    )}
                  </div>
                </div>
    
                {/* Mode */}
                <div>
                  <div className="text-muted-foreground mb-2 text-xs font-medium tracking-wide uppercase">Mode</div>
                  <div className="bg-muted/50 grid grid-cols-3 gap-1 rounded-md p-1">
                    {modes.map((m) => {
                      const Icon = m.icon
                      return (
                        <button
                          key={m.value}
                          type="button"
                          aria-pressed={mode === m.value}
                          className={[
                            'flex items-center justify-center gap-1.5 rounded px-2 py-1.5 text-xs font-medium transition-colors',
                            mode === m.value ? 'bg-background shadow-sm' : 'text-muted-foreground hover:text-foreground',
                          ].join(' ')}
                          onClick={() => setTheme(m.value)}
                        >
                          <Icon className="size-3.5" />
                          {m.label}
                        </button>
                      )
                    })}
                  </div>
                </div>
    
                {/* Color */}
                <div>
                  <div className="mb-2 flex items-baseline justify-between">
                    <span className="text-muted-foreground text-xs font-medium tracking-wide uppercase">Color</span>
                    <span className="text-muted-foreground text-[10px]">{PRESETS.length} options</span>
                  </div>
                  <div className="grid grid-cols-10 gap-1.5">
                    {PRESETS.map((p) => (
                      <button
                        key={p.key}
                        type="button"
                        aria-label={p.label}
                        aria-pressed={preset === p.key}
                        title={p.label}
                        className={[
                          'group ring-offset-background relative aspect-square rounded-md transition-all',
                          preset === p.key ? 'ring-ring ring-2 ring-offset-2' : 'hover:scale-110',
                        ].join(' ')}
                        style={{ background: p.swatch }}
                        onClick={() => setPreset(p.key)}
                      >
                        {preset === p.key && (
                          <Check className="absolute inset-0 m-auto size-3 text-white mix-blend-difference" />
                        )}
                      </button>
                    ))}
    
                    {/* Custom color slot */}
                    <label
                      className={[
                        'relative flex aspect-square cursor-pointer items-center justify-center rounded-md border transition-all',
                        preset === 'custom'
                          ? 'ring-ring ring-offset-background border-transparent ring-2 ring-offset-2'
                          : 'border-dashed hover:scale-110',
                      ].join(' ')}
                      style={preset === 'custom' ? { background: customColor } : {}}
                      aria-label="Custom color"
                      title="Custom color"
                    >
                      <input
                        type="color"
                        value={customColor}
                        className="sr-only"
                        onChange={(e) => {
                          setCustomColor(e.target.value)
                          setPreset('custom')
                        }}
                      />
                      {preset !== 'custom' ? (
                        <Pipette className="text-muted-foreground size-3" />
                      ) : (
                        <Check className="size-3 text-white mix-blend-difference" />
                      )}
                    </label>
                  </div>
                </div>
    
                {/* Surface tint */}
                <div>
                  <div className="text-muted-foreground mb-2 text-xs font-medium tracking-wide uppercase">Surface</div>
                  <div className="bg-muted/50 grid grid-cols-3 gap-1 rounded-md p-1">
                    {TINTS.map((t) => (
                      <button
                        key={t.key}
                        type="button"
                        aria-pressed={tint === t.key}
                        className={[
                          'rounded px-2 py-1.5 text-xs font-medium transition-colors',
                          tint === t.key ? 'bg-background shadow-sm' : 'text-muted-foreground hover:text-foreground',
                        ].join(' ')}
                        onClick={() => setTint(t.key)}
                      >
                        {t.label}
                      </button>
                    ))}
                  </div>
                </div>
    
                {/* Font */}
                <div>
                  <div className="text-muted-foreground mb-2 text-xs font-medium tracking-wide uppercase">Font</div>
                  <div className="bg-muted/50 grid grid-cols-3 gap-1 rounded-md p-1">
                    {FONTS.map((f) => (
                      <button
                        key={f.key}
                        type="button"
                        aria-pressed={font === f.key}
                        className={[
                          'rounded px-2 py-1.5 text-xs font-medium transition-colors',
                          font === f.key ? 'bg-background shadow-sm' : 'text-muted-foreground hover:text-foreground',
                        ].join(' ')}
                        style={{ fontFamily: f.stack }}
                        onClick={() => setFont(f.key)}
                      >
                        {f.label}
                      </button>
                    ))}
                  </div>
                </div>
    
                {/* Radius */}
                <div>
                  <div className="mb-2 flex items-baseline justify-between">
                    <span className="text-muted-foreground text-xs font-medium tracking-wide uppercase">Radius</span>
                    <span className="text-muted-foreground font-mono text-[10px]">{radius.toFixed(2)}rem</span>
                  </div>
                  <Slider value={[radius]} min={0} max={1} step={0.05} onValueChange={([v]) => setRadius(v)} />
                  <div className="text-muted-foreground mt-1 flex justify-between text-[10px]">
                    <span>0</span>
                    <span>0.5</span>
                    <span>1</span>
                  </div>
                </div>
    
                {/* Live preview */}
                <div>
                  <div className="text-muted-foreground mb-2 text-xs font-medium tracking-wide uppercase">Preview</div>
                  <div className="bg-card space-y-2.5 rounded-lg border p-3">
                    <div className="flex items-center gap-2">
                      <Button size="sm" className="text-xs">
                        Primary
                      </Button>
                      <Button size="sm" variant="outline" className="text-xs">
                        Outline
                      </Button>
                      <span className="bg-primary/10 text-primary rounded-md px-2 py-0.5 text-[10px] font-medium">Badge</span>
                    </div>
                    <div className="ring-ring/40 bg-background border-input flex h-8 items-center rounded-md border px-2 text-xs focus-within:ring-2">
                      Input sample
                    </div>
                    <p className="text-muted-foreground text-[11px] leading-relaxed">
                      The quick brown fox jumps over the lazy dog.
                    </p>
                  </div>
                </div>
              </div>
            </div>
            {/* Footer actions (sticky bottom) */}
            <div className="bg-background/95 flex gap-2 border-t px-6 py-3 backdrop-blur">
              <Button variant="ghost" size="sm" className="flex-1 gap-1.5" onClick={reset}>
                <RotateCcw className="size-3.5" /> Reset
              </Button>
              <Button variant="outline" size="sm" className="flex-1 gap-1.5" onClick={exportJson}>
                <Download className="size-3.5" />
                {exported ? 'Saved' : 'Export'}
              </Button>
              <Button variant="default" size="sm" className="flex-1 gap-1.5" onClick={copyCss}>
                {copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
                {copied ? 'Copied' : 'Copy CSS'}
              </Button>
            </div>
          </SheetContent>
        </Sheet>
      )
    }

Raw manifest: https://react.uipkge.dev/r/react/theme-customize.json