Menu

Change language

GitHub repo

Theme Customize

blocklayout

Compact theme customization sheet. Light/Dark/System mode (via a cookie-backed theme store), 20 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.

Installation

$npx shadcn-svelte@latest add https://uipkge.dev/r/svelte/theme-customize.json
Manifest:https://uipkge.dev/r/svelte/theme-customize.jsonInstalls to:src/lib/components/blocks/theme-customize/

Variants

Loading interactive previews…

Schema

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

ThemePresetTokens
interface ThemePresetTokens {
  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
  category: CategoryKey
  mode: Mode
  color: PresetKey
  tint: TintKey
  font: FontKey
  radius: number
}
FontOption
interface FontOption {
  key: FontKey
  label: string
  stack: string
}
TintOption
interface TintOption {
  key: TintKey
  label: string
  light: string
  dark: string
}

npm dependencies

Files installed (4)

  • src/lib/components/blocks/theme-customize/ThemeCustomize.svelte22.7 kB
    <script lang="ts" module>
      export interface ThemeCustomizeProps {
        class?: string
      }
    </script>
    
    <script lang="ts">
      import { onDestroy, onMount, tick, untrack } from 'svelte'
      import type { Component } from 'svelte'
      import { Check, Copy, Download, Monitor, Moon, Palette, Pipette, RotateCcw, Shuffle, Sun } from '@lucide/svelte'
      import { Button } from '$lib/components/ui/button'
      import { Slider } from '$lib/components/ui/slider'
      import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from '$lib/components/ui/sheet'
      import {
        CATEGORY_LABELS,
        CATEGORY_ORDER,
        FONTS,
        PRESETS,
        STORAGE_KEY,
        THEMES,
        TINTS,
        type CategoryKey,
        type FontKey,
        type Mode,
        type PresetKey,
        type ThemePreset,
        type TintKey,
      } from './theme-data'
      import { themeState } from './theme.svelte'
    
      let { class: className }: ThemeCustomizeProps = $props()
    
      let preset: PresetKey = $state('neutral')
      let customColor = $state('#ec4899')
      let font: FontKey = $state('sans')
      let tint: TintKey = $state('pure')
      let radius = $state(0.3)
      let copied = $state(false)
      let exported = $state(false)
      let themeSearch = $state('')
      let sheetOpen = $state(false)
    
      const activeTheme = $derived<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.
        (THEMES.find(
          (t) => t.mode === themeState.current && t.color === preset && t.tint === tint && t.font === font,
        ) ?? null),
      )
    
      const filteredThemes = $derived.by(() => {
        const q = themeSearch.trim().toLowerCase()
        if (!q) return THEMES
        return THEMES.filter((t) => t.label.toLowerCase().includes(q) || t.key.includes(q))
      })
    
      const themesByCategory = $derived.by<{ key: CategoryKey; label: string; items: ThemePreset[] }[]>(() =>
        CATEGORY_ORDER.map((c) => ({
          key: c,
          label: CATEGORY_LABELS[c],
          items: filteredThemes.filter((t) => t.category === c),
        })).filter((g) => g.items.length > 0),
      )
    
      const modes: { value: Mode; label: string; icon: Component }[] = [
        { 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')
      }
    
      function applyPreset(key: PresetKey) {
        if (typeof window === 'undefined') return
        const root = document.documentElement
        if (key === 'custom') {
          root.style.setProperty('--primary', customColor)
          root.style.setProperty('--primary-foreground', '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)
      }
    
      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`)
      }
    
      function save() {
        try {
          localStorage.setItem(
            STORAGE_KEY,
            JSON.stringify({ preset, customColor, font, tint, radius }),
          )
        } catch {
          // ignore
        }
      }
    
      function load() {
        try {
          const raw = localStorage.getItem(STORAGE_KEY)
          if (!raw) return
          const parsed = JSON.parse(raw)
          if (parsed.preset) preset = parsed.preset
          if (parsed.customColor) customColor = parsed.customColor
          if (parsed.font) font = parsed.font
          if (parsed.tint) tint = parsed.tint
          if (typeof parsed.radius === 'number') radius = parsed.radius
        } catch {
          // ignore
        }
      }
    
      function applyAll() {
        applyPreset(preset)
        applyTint(tint)
        applyFont(font)
        applyRadius(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.
        themeState.set(t.mode)
        preset = t.color
        tint = t.tint
        font = 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() {
        preset = 'neutral'
        customColor = '#ec4899'
        font = 'sans'
        tint = 'pure'
        radius = 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())
          copied = true
          setTimeout(() => (copied = 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)
        exported = true
        setTimeout(() => (exported = false), 1600)
      }
    
      // Token watchers: apply + persist. save() is untracked so a persist never
      // re-triggers the apply it just performed.
      $effect(() => {
        applyPreset(preset)
        untrack(save)
      })
      $effect(() => {
        applyFont(font)
        untrack(save)
      })
      $effect(() => {
        applyTint(tint)
        untrack(save)
      })
      $effect(() => {
        applyRadius(radius)
        untrack(save)
      })
    
      $effect(() => {
        // Re-apply color tokens once the mode flip has committed the dark class.
        void themeState.current
        tick().then(() => {
          applyPreset(preset)
          applyTint(tint)
        })
      })
    
      // 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)
      }
    
      $effect(() => {
        void preset
        void customColor
        void font
        void tint
        void radius
        void themeState.current
        untrack(pulseTransition)
      })
    
      function onKeydown(e: KeyboardEvent) {
        // Cmd/Ctrl + J -> toggle the customizer
        if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'j') {
          e.preventDefault()
          sheetOpen = !sheetOpen
        }
      }
    
      onMount(() => {
        load()
        themeState.apply()
        applyAll()
        if (typeof window !== 'undefined') window.addEventListener('keydown', onKeydown)
      })
    
      onDestroy(() => {
        if (typeof window !== 'undefined') window.removeEventListener('keydown', onKeydown)
      })
    
      const activeSwatch = $derived(PRESETS.find((p) => p.key === activeTheme?.color)?.swatch)
    </script>
    
    <!-- Sheet renders only its children, so the slot marker lives on a layout-free wrapper. -->
    <div data-slot="theme-customize" class={['contents', className]}>
    <Sheet bind:open={sheetOpen}>
      <SheetTrigger>
        {#snippet child({ props })}
          <!-- class after the spread: trigger props carry no classes of their own. -->
          <Button variant="outline" size="sm" {...props} class="gap-2">
            <Palette class="size-4" />
            <span class="hidden sm:inline">Customize</span>
            <kbd class="bg-muted text-muted-foreground hidden rounded px-1 py-0.5 text-xs font-medium sm:inline-block"
              >⌘J</kbd
            >
          </Button>
        {/snippet}
      </SheetTrigger>
      <SheetContent side="right" class="flex w-full flex-col gap-0 p-0 sm:max-w-md">
        <SheetHeader class="border-b px-6 py-4">
          <div class="flex items-center justify-between gap-3">
            <SheetTitle class="flex items-center gap-2 text-base"><Palette class="size-4" /> Customize</SheetTitle>
            <!-- Active theme chip -->
            {#if activeTheme}
              <span
                class="bg-muted text-foreground inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium"
              >
                <span class="size-2 rounded-full shadow-2xs" style:background={activeSwatch}></span>
                {activeTheme.label}
              </span>
            {:else}
              <span class="text-muted-foreground bg-muted/50 rounded-full px-2 py-0.5 text-xs font-medium">Custom</span>
            {/if}
          </div>
          <SheetDescription class="text-xs">
            Live-edit tokens. Changes write to <code class="font-mono">:root</code> and persist locally.
          </SheetDescription>
          <!-- Quick actions -->
          <div class="mt-2 flex gap-2">
            <Button variant="outline" size="xs" class="gap-1.5" onclick={applyRandomTheme}>
              <Shuffle class="size-3" /> Surprise me
            </Button>
          </div>
        </SheetHeader>
        <div class="flex-1 overflow-y-auto px-6 py-5">
          <div class="space-y-6">
            <!-- Curated themes -->
            <div>
              <div class="mb-2 flex items-baseline justify-between">
                <span class="text-muted-foreground text-xs font-medium tracking-wide uppercase">Themes</span>
                <span class="text-muted-foreground text-xs">
                  {filteredThemes.length}{#if themeSearch} / {THEMES.length}{/if} presets
                </span>
              </div>
    
              <!-- Search -->
              <div class="relative mb-3">
                <input
                  bind:value={themeSearch}
                  type="search"
                  placeholder="Search themes..."
                  class="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"
                  class="text-muted-foreground absolute top-1/2 left-2 size-3.5 -translate-y-1/2"
                  fill="none"
                  stroke="currentColor"
                  stroke-width="2"
                  stroke-linecap="round"
                  stroke-linejoin="round"
                  aria-hidden="true"
                >
                  <circle cx="11" cy="11" r="7" />
                  <path d="m21 21-4.3-4.3" />
                </svg>
              </div>
    
              <!-- Categorized groups -->
              <div class="space-y-4">
                {#each themesByCategory as group (group.key)}
                  <div>
                    <div class="text-muted-foreground/70 mb-1.5 text-xs font-medium tracking-wider uppercase">
                      {group.label}
                    </div>
                    <div class="grid grid-cols-3 gap-2">
                      {#each group.items as t (t.key)}
                        {@const tintOption = TINTS.find((x) => x.key === t.tint)}
                        {@const colorSwatch = PRESETS.find((p) => p.key === t.color)?.swatch}
                        {@const fontStack = FONTS.find((f) => f.key === t.font)?.stack}
                        {@const isActive = activeTheme?.key === t.key}
                        {@const previewBg = t.mode === 'dark' ? tintOption?.dark : tintOption?.light}
                        {@const previewFg = t.mode === 'dark' ? 'oklch(0.985 0 0)' : 'oklch(0.205 0 0)'}
                        <button
                          type="button"
                          title={`${t.mode} · ${t.color} · ${t.font} · r=${t.radius}`}
                          aria-pressed={isActive}
                          class={[
                            'group relative flex flex-col overflow-hidden rounded-lg border text-left transition-[box-shadow,scale]',
                            isActive
                              ? 'ring-ring ring-offset-background border-transparent ring-2 ring-offset-2'
                              : 'hover:scale-[1.03] hover:shadow-md',
                          ]}
                          onclick={() => applyTheme(t)}
                        >
                          <!-- Mini-preview window -->
                          <div
                            class="relative h-14 px-2 py-1.5"
                            style:background={previewBg}
                            style:color={previewFg}
                          >
                            <!-- Top row: dot + brand swatch dot -->
                            <div class="flex items-center justify-between">
                              <div class="flex gap-0.5">
                                <span class="size-1.5 rounded-full opacity-40" style:background="currentColor"></span>
                                <span class="size-1.5 rounded-full opacity-25" style:background="currentColor"></span>
                                <span class="size-1.5 rounded-full opacity-15" style:background="currentColor"></span>
                              </div>
                              <span class="size-2 rounded-full shadow-2xs" style:background={colorSwatch}></span>
                            </div>
                            <!-- Mini button + chip preview -->
                            <div class="mt-2 flex items-center gap-1">
                              <span
                                class="inline-block h-3 px-1.5 text-xs leading-3 font-semibold"
                                style:background={colorSwatch}
                                style:color="oklch(0.985 0 0)"
                                style:border-radius={`${Math.min(t.radius, 0.5)}rem`}
                                style:font-family={fontStack}>Aa</span
                              >
                              <span class="block h-1 flex-1 rounded-full opacity-30" style:background="currentColor"></span>
                            </div>
                            <!-- Lower text bar -->
                            <div class="mt-1.5 flex gap-1">
                              <span class="block h-0.5 w-3 rounded-full opacity-20" style:background="currentColor"></span>
                              <span class="block h-0.5 w-5 rounded-full opacity-15" style:background="currentColor"></span>
                            </div>
                          </div>
                          <!-- Label strip -->
                          <div class="bg-muted/40 flex items-center justify-between px-2 py-1">
                            <span class="text-xs leading-none font-medium">{t.label}</span>
                            {#if isActive}
                              <Check class="text-primary size-3" />
                            {/if}
                          </div>
                        </button>
                      {/each}
                    </div>
                  </div>
                {/each}
                {#if filteredThemes.length === 0}
                  <div class="text-muted-foreground py-6 text-center text-xs">
                    No themes match "<span class="font-mono">{themeSearch}</span>"
                  </div>
                {/if}
              </div>
            </div>
    
            <!-- Mode -->
            <div>
              <div class="text-muted-foreground mb-2 text-xs font-medium tracking-wide uppercase">Mode</div>
              <div class="bg-muted/50 grid grid-cols-3 gap-1 rounded-md p-1">
                {#each modes as m (m.value)}
                  <button
                    type="button"
                    aria-pressed={themeState.current === m.value}
                    class={[
                      'flex items-center justify-center gap-1.5 rounded px-2 py-1.5 text-xs font-medium transition-colors',
                      themeState.current === m.value
                        ? 'bg-background shadow-sm'
                        : 'text-muted-foreground hover:text-foreground',
                    ]}
                    onclick={() => themeState.set(m.value)}
                  >
                    <m.icon class="size-3.5" />
                    {m.label}
                  </button>
                {/each}
              </div>
            </div>
    
            <!-- Color -->
            <div>
              <div class="mb-2 flex items-baseline justify-between">
                <span class="text-muted-foreground text-xs font-medium tracking-wide uppercase">Color</span>
                <span class="text-muted-foreground text-xs">{PRESETS.length} options</span>
              </div>
              <div class="grid grid-cols-10 gap-1.5">
                {#each PRESETS as p (p.key)}
                  <button
                    type="button"
                    aria-label={p.label}
                    aria-pressed={preset === p.key}
                    title={p.label}
                    class={[
                      'group ring-offset-background relative aspect-square rounded-md transition-transform',
                      preset === p.key ? 'ring-ring ring-2 ring-offset-2' : 'hover:scale-110',
                    ]}
                    style:background={p.swatch}
                    onclick={() => (preset = p.key)}
                  >
                    {#if preset === p.key}
                      <Check class="absolute inset-0 m-auto size-3 text-white mix-blend-difference" />
                    {/if}
                  </button>
                {/each}
    
                <!-- Custom color slot -->
                <label
                  class={[
                    'relative flex aspect-square cursor-pointer items-center justify-center rounded-md border transition-transform',
                    preset === 'custom'
                      ? 'ring-ring ring-offset-background border-transparent ring-2 ring-offset-2'
                      : 'border-dashed hover:scale-110',
                  ]}
                  style:background={preset === 'custom' ? customColor : undefined}
                  aria-label="Custom color"
                  title="Custom color"
                >
                  <input
                    type="color"
                    bind:value={customColor}
                    class="sr-only"
                    oninput={() => (preset = 'custom')}
                  />
                  {#if preset !== 'custom'}
                    <Pipette class="text-muted-foreground size-3" />
                  {:else}
                    <Check class="size-3 text-white mix-blend-difference" />
                  {/if}
                </label>
              </div>
            </div>
    
            <!-- Surface tint -->
            <div>
              <div class="text-muted-foreground mb-2 text-xs font-medium tracking-wide uppercase">Surface</div>
              <div class="bg-muted/50 grid grid-cols-3 gap-1 rounded-md p-1">
                {#each TINTS as t (t.key)}
                  <button
                    type="button"
                    aria-pressed={tint === t.key}
                    class={[
                      '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',
                    ]}
                    onclick={() => (tint = t.key)}
                  >
                    {t.label}
                  </button>
                {/each}
              </div>
            </div>
    
            <!-- Font -->
            <div>
              <div class="text-muted-foreground mb-2 text-xs font-medium tracking-wide uppercase">Font</div>
              <div class="bg-muted/50 grid grid-cols-3 gap-1 rounded-md p-1">
                {#each FONTS as f (f.key)}
                  <button
                    type="button"
                    aria-pressed={font === f.key}
                    class={[
                      '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',
                    ]}
                    style:font-family={f.stack}
                    onclick={() => (font = f.key)}
                  >
                    {f.label}
                  </button>
                {/each}
              </div>
            </div>
    
            <!-- Radius -->
            <div>
              <div class="mb-2 flex items-baseline justify-between">
                <span class="text-muted-foreground text-xs font-medium tracking-wide uppercase">Radius</span>
                <span class="text-muted-foreground font-mono text-xs">{radius.toFixed(2)}rem</span>
              </div>
              <Slider bind:value={radius} min={0} max={1} step={0.05} />
              <div class="text-muted-foreground mt-1 flex justify-between text-xs">
                <span>0</span>
                <span>0.5</span>
                <span>1</span>
              </div>
            </div>
    
            <!-- Live preview -->
            <div>
              <div class="text-muted-foreground mb-2 text-xs font-medium tracking-wide uppercase">Preview</div>
              <div class="bg-card space-y-2.5 rounded-lg border p-3">
                <div class="flex items-center gap-2">
                  <Button size="sm" class="text-xs">Primary</Button>
                  <Button size="sm" variant="outline" class="text-xs">Outline</Button>
                  <span class="bg-primary/10 text-primary rounded-md px-2 py-0.5 text-xs font-medium">Badge</span>
                </div>
                <div
                  class="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 class="text-muted-foreground text-xs leading-relaxed">The quick brown fox jumps over the lazy dog.</p>
              </div>
            </div>
          </div>
        </div>
        <!-- Footer actions (sticky bottom) -->
        <div class="bg-background/95 flex gap-2 border-t px-6 py-3 backdrop-blur">
          <Button variant="ghost" size="sm" class="flex-1 gap-1.5" onclick={reset}>
            <RotateCcw class="size-3.5" /> Reset
          </Button>
          <Button aria-label="Download attachment" variant="outline" size="sm" class="flex-1 gap-1.5" onclick={exportJson}>
            <Download class="size-3.5" />
            {exported ? 'Saved' : 'Export'}
          </Button>
          <Button variant="default" size="sm" class="flex-1 gap-1.5" onclick={copyCss}>
            {#if copied}
              <Check class="size-3.5" />
            {:else}
              <Copy class="size-3.5" />
            {/if}
            {copied ? 'Copied' : 'Copy CSS'}
          </Button>
        </div>
      </SheetContent>
    </Sheet>
    </div>
    
  • src/lib/components/blocks/theme-customize/theme-data.ts9.3 kB
  • src/lib/components/blocks/theme-customize/theme.svelte.ts2.2 kB
  • src/routes/theme-customize-demo/+page.svelte0.5 kB

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