Theme Customize
blocklayoutCompact 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.
Installation
$pnpm dlx shadcn@latest add https://uipkge.dev/r/lit/theme-customize.json$npx shadcn@latest add https://uipkge.dev/r/lit/theme-customize.json$yarn dlx shadcn@latest add https://uipkge.dev/r/lit/theme-customize.json$bunx shadcn@latest add https://uipkge.dev/r/lit/theme-customize.jsonManifest:
https://uipkge.dev/r/lit/theme-customize.jsonInstalls to:src/uip/blocks/theme-customize/Variants
Loading interactive previews…
Loading interactive previews…
Schema
Type aliases exported from this item's source. Use these to shape the data you pass in.
Presetinterface Preset {
key: PresetKey
label: string
swatch: string
light: { primary: string; primaryForeground: string; ring: string }
dark: { primary: string; primaryForeground: string; ring: string }
}ThemePresetinterface ThemePreset {
key: ThemeKey
label: string
category: CategoryKey
mode: Mode
color: PresetKey
tint: TintKey
font: FontKey
radius: number
}HTMLElementTagNameMapinterface HTMLElementTagNameMap {
'uip-theme-customize': UipThemeCustomize
}
}Files installed (1)
src/uip/blocks/theme-customize/theme-customize.ts33.5 kB
import { LitElement, css, html, nothing } from 'lit' import { Check, Copy, Download, Monitor, Moon, Palette, Pipette, RotateCcw, Search, Shuffle, Sun, } from 'lucide' import { tailwind } from '../../lib/styles' import { ThemeController } from '../../lib/theme' import { icon } from '../../lib/icon' // Side-effect imports: register the composed elements for standalone use // (consumers who install only this block still get working primitives). import '../../components/button/button' import '../../components/input/input' import type { UipInput } from '../../components/input/input' import '../../components/sheet/sheet' import '../../components/slider/slider' 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 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', category: 'featured', mode: 'dark', color: 'amber', tint: 'warm', font: 'serif', radius: 0.75 }, { key: 'sunset', label: 'Sunset', category: 'featured', mode: 'light', color: 'orange', tint: 'warm', font: 'sans', radius: 0.625 }, { key: 'cyberpunk', label: 'Cyberpunk', category: 'featured', mode: 'dark', color: 'fuchsia', tint: 'cool', font: 'mono', radius: 0.125 }, { key: 'forest', label: 'Forest', category: 'featured', mode: 'light', color: 'emerald', tint: 'warm', font: 'serif', radius: 0.5 }, { key: 'ocean', label: 'Ocean', category: 'featured', mode: 'dark', color: 'cyan', tint: 'cool', font: 'sans', radius: 0.5 }, { key: 'pastel', label: 'Pastel', category: 'featured', mode: 'light', color: 'rose', tint: 'pure', font: 'sans', radius: 0.875 }, { key: 'mono', label: 'Mono', category: 'editorial', mode: 'light', color: 'neutral', tint: 'pure', font: 'mono', radius: 0 }, { key: 'brutalist', label: 'Brutalist', category: 'editorial', mode: 'light', color: 'neutral', tint: 'pure', font: 'mono', radius: 0 }, { key: 'notebook', label: 'Notebook', category: 'editorial', mode: 'light', color: 'stone', tint: 'warm', font: 'serif', radius: 0.25 }, { key: 'vintage', label: 'Vintage', category: 'editorial', mode: 'light', color: 'amber', tint: 'warm', font: 'serif', radius: 0.375 }, { key: 'caffeine', label: 'Caffeine', category: 'editorial', mode: 'light', color: 'orange', tint: 'warm', font: 'serif', radius: 0.5 }, { key: 'marble', label: 'Marble', category: 'editorial', mode: 'light', color: 'stone', tint: 'pure', font: 'serif', radius: 0.25 }, { key: 'retro', label: 'Retro', category: 'bold', mode: 'dark', color: 'lime', tint: 'cool', font: 'mono', radius: 0 }, { key: 'tangerine', label: 'Tangerine', category: 'bold', mode: 'light', color: 'orange', tint: 'pure', font: 'sans', radius: 0.75 }, { key: 'ember', label: 'Ember', category: 'bold', mode: 'dark', color: 'red', tint: 'warm', font: 'sans', radius: 0.375 }, { key: 'tropical', label: 'Tropical', category: 'bold', mode: 'light', color: 'teal', tint: 'cool', font: 'sans', radius: 0.625 }, { key: 'midnight', label: 'Midnight', category: 'cool', mode: 'dark', color: 'indigo', tint: 'cool', font: 'sans', radius: 0.5 }, { key: 'cosmic', label: 'Cosmic', category: 'cool', mode: 'dark', color: 'violet', tint: 'cool', font: 'sans', radius: 0.625 }, { key: 'rainfall', label: 'Rainfall', category: 'cool', mode: 'dark', color: 'sky', tint: 'cool', font: 'sans', radius: 0.5 }, { key: 'amethyst', label: 'Amethyst', category: 'cool', mode: 'dark', color: 'purple', tint: 'cool', font: 'sans', radius: 0.625 }, { key: 'sage', label: 'Sage', category: 'soft', mode: 'light', color: 'emerald', tint: 'warm', font: 'serif', radius: 0.75 }, { key: 'peach', label: 'Peach', category: 'soft', mode: 'light', color: 'amber', tint: 'warm', font: 'sans', radius: 0.875 }, { key: 'quartz', label: 'Quartz', category: 'soft', mode: 'light', color: 'violet', tint: 'cool', font: 'sans', radius: 0.875 }, { key: 'mocha', label: 'Mocha', 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: typeof Sun }[] = [ { value: 'light', label: 'Light', icon: Sun }, { value: 'dark', label: 'Dark', icon: Moon }, { value: 'system', label: 'System', icon: Monitor }, ] /** * <uip-theme-customize> — the registry ThemeCustomize block as a web component. * * Class strings are React's ThemeCustomize verbatim; the PRESETS / THEMES / * FONTS / TINTS tables are copied verbatim. React state (useState) becomes * { state: true } fields; React's useEffect watchers become explicit * apply-after-set calls. There are no onX callbacks in React, so no * CustomEvents. * * Deviations: `next-themes` is not a Lit dependency — mode writes the * `dark` class on documentElement directly (`system` follows matchMedia), * and the search field's absolute overlay icon moves into <uip-input>'s * `prefix` slot (same visual result). */ export class UipThemeCustomize extends LitElement { static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true } // :host display can't be a utility class (the host has no template of its own). static styles = [tailwind, css`:host { display: block; }`] static properties = { mode: { state: true }, preset: { state: true }, customColor: { state: true }, font: { state: true }, tint: { state: true }, radius: { state: true }, copied: { state: true }, exported: { state: true }, themeSearch: { state: true }, sheetOpen: { state: true }, } private mode: Mode = 'system' private preset: PresetKey = 'neutral' private customColor = '#ec4899' private font: FontKey = 'sans' private tint: TintKey = 'pure' private radius = 0.3 private copied = false private exported = false private themeSearch = '' private sheetOpen = false private timers: number[] = [] private media?: MediaQueryList constructor() { super() new ThemeController(this) } connectedCallback() { super.connectedCallback() this.setAttribute('data-uipkge', '') this.setAttribute('data-slot', 'theme-customize') try { const raw = localStorage.getItem(STORAGE_KEY) if (raw) { const parsed = JSON.parse(raw) if (parsed.preset) this.preset = parsed.preset if (parsed.customColor) this.customColor = parsed.customColor if (parsed.font) this.font = parsed.font if (parsed.tint) this.tint = parsed.tint if (typeof parsed.radius === 'number') this.radius = parsed.radius } } catch { // ignore } this.media = window.matchMedia('(prefers-color-scheme: dark)') this.media.addEventListener('change', this.onSystemChange) window.addEventListener('keydown', this.onKeydown) this.applyAll() } disconnectedCallback() { super.disconnectedCallback() this.media?.removeEventListener('change', this.onSystemChange) window.removeEventListener('keydown', this.onKeydown) this.timers.forEach((t) => window.clearTimeout(t)) } private onSystemChange = () => { if (this.mode === 'system') this.applyAll() } private onKeydown = (e: KeyboardEvent) => { // Cmd/Ctrl + J -> toggle the customizer if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'j') { e.preventDefault() this.sheetOpen = !this.sheetOpen } } private isDarkActive(): boolean { if (this.mode === 'dark') return true if (this.mode === 'light') return false return this.media?.matches ?? document.documentElement.classList.contains('dark') } private get activeTheme(): ThemePreset | null { return THEMES.find((t) => t.mode === this.mode && t.color === this.preset && t.tint === this.tint && t.font === this.font) ?? null } private get filteredThemes(): ThemePreset[] { const q = this.themeSearch.trim().toLowerCase() if (!q) return THEMES return THEMES.filter((t) => t.label.toLowerCase().includes(q) || t.key.includes(q)) } private get themesByCategory(): { 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: this.filteredThemes.filter((t) => t.category === c) })) .filter((g) => g.items.length > 0) } private later(fn: () => void, ms: number) { this.timers.push(window.setTimeout(fn, ms)) } private pulseTransition() { const root = document.documentElement root.style.transition = 'background-color 240ms ease, color 240ms ease' this.later(() => { root.style.transition = '' }, 300) } private applyPreset(key: PresetKey) { const root = document.documentElement if (key === 'custom') { root.style.setProperty('--primary', this.customColor) root.style.setProperty('--primary-foreground', 'oklch(0.985 0 0)') root.style.setProperty('--ring', this.customColor) return } const found = PRESETS.find((p) => p.key === key) if (!found) return const tokens = this.isDarkActive() ? found.dark : found.light root.style.setProperty('--primary', tokens.primary) root.style.setProperty('--primary-foreground', tokens.primaryForeground) root.style.setProperty('--ring', tokens.ring) } private applyTint(key: TintKey) { const t = TINTS.find((x) => x.key === key) if (!t) return document.documentElement.style.setProperty('--background', this.isDarkActive() ? t.dark : t.light) } private applyFont(key: FontKey) { const f = FONTS.find((x) => x.key === key) if (!f) return document.documentElement.style.setProperty('--font-sans', f.stack) } private applyRadius(rem: number) { document.documentElement.style.setProperty('--radius', `${rem}rem`) } private applyMode(mode: Mode) { const root = document.documentElement if (mode === 'dark') root.classList.add('dark') else if (mode === 'light') root.classList.remove('dark') else root.classList.toggle('dark', this.media?.matches ?? false) } private save() { try { localStorage.setItem( STORAGE_KEY, JSON.stringify({ preset: this.preset, customColor: this.customColor, font: this.font, tint: this.tint, radius: this.radius }), ) } catch { // ignore } } private applyAll() { this.applyMode(this.mode) this.applyPreset(this.preset) this.applyTint(this.tint) this.applyFont(this.font) this.applyRadius(this.radius) this.save() this.pulseTransition() } private setMode(mode: Mode) { this.mode = mode this.applyAll() } private setPreset(key: PresetKey) { this.preset = key this.applyAll() } private setTint(key: TintKey) { this.tint = key this.applyAll() } private setFont(key: FontKey) { this.font = key this.applyAll() } private setRadius(rem: number) { this.radius = rem this.applyAll() } private 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. this.mode = t.mode this.preset = t.color this.tint = t.tint this.font = t.font this.applyAll() } private applyRandomTheme() { const candidates = THEMES.filter((t) => t.key !== this.activeTheme?.key) const pick = candidates[Math.floor(Math.random() * candidates.length)] if (pick) this.applyTheme(pick) } private reset() { this.preset = 'neutral' this.customColor = '#ec4899' this.font = 'sans' this.tint = 'pure' this.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 } } private buildCss(): string { const found = PRESETS.find((p) => p.key === this.preset) ?? PRESETS[0] const isCustom = this.preset === 'custom' const tokens = this.isDarkActive() ? found.dark : found.light const t = TINTS.find((x) => x.key === this.tint) ?? TINTS[0] const f = FONTS.find((x) => x.key === this.font) ?? FONTS[0] const primary = isCustom ? this.customColor : tokens.primary const primaryFg = isCustom ? 'oklch(0.985 0 0)' : tokens.primaryForeground const ring = isCustom ? this.customColor : tokens.ring return `:root { --background: ${this.isDarkActive() ? t.dark : t.light}; --primary: ${primary}; --primary-foreground: ${primaryFg}; --ring: ${ring}; --radius: ${this.radius}rem; --font-sans: ${f.stack}; }` } private async copyCss() { try { await navigator.clipboard.writeText(this.buildCss()) this.copied = true this.later(() => { this.copied = false }, 1600) } catch { // ignore } } private exportJson() { const blob = new Blob( [ JSON.stringify( { preset: this.preset, customColor: this.customColor, font: this.font, tint: this.tint, radius: this.radius, css: this.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) this.exported = true this.later(() => { this.exported = false }, 1600) } render() { const activeTheme = this.activeTheme return html` <uip-sheet data-slot="theme-customize" side="right" class="[&::part(content)]:flex [&::part(content)]:w-full [&::part(content)]:flex-col [&::part(content)]:gap-0 [&::part(content)]:p-0 [&::part(content)]:sm:max-w-md [&::part(footer)]:px-6 [&::part(footer)]:py-3" ?open=${this.sheetOpen} @open-change=${(e: CustomEvent<{ open: boolean }>) => { this.sheetOpen = e.detail.open }} > <uip-button slot="trigger" variant="outline" size="sm" class="gap-2"> ${icon(Palette, 'palette', '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> </uip-button> <div slot="title" class="flex items-center justify-between gap-3"> <span class="flex items-center gap-2 text-base">${icon(Palette, 'palette', 'size-4')} Customize</span> ${activeTheme ? html`<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: ${PRESETS.find((p) => p.key === activeTheme.color)?.swatch}`} ></span> ${activeTheme.label} </span>` : html`<span class="text-muted-foreground bg-muted/50 rounded-full px-2 py-0.5 text-xs font-medium">Custom</span>`} </div> <div slot="description" class="text-xs"> Live-edit tokens. Changes write to <code class="font-mono">:root</code> and persist locally. </div> <div class="px-6 py-4"> <div class="mt-2 flex gap-2"> <uip-button variant="outline" size="xs" class="gap-1.5" @click=${this.applyRandomTheme}> ${icon(Shuffle, 'shuffle', 'size-3')} Surprise me </uip-button> </div> </div> <uip-sheet-body class="[&::part(base)]:px-6 [&::part(base)]:py-5"> <div class="space-y-6"> <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"> ${this.filteredThemes.length}${this.themeSearch ? html`<span> / ${THEMES.length}</span>` : nothing} presets </span> </div> <div class="mb-3"> <uip-input .value=${this.themeSearch} type="search" placeholder="Search themes..." class="h-8 text-xs" @input=${(e: Event) => { this.themeSearch = (e.target as UipInput).value }} > <span slot="prefix">${icon(Search, 'search', 'size-3.5')}</span> </uip-input> </div> <div class="space-y-4"> ${this.themesByCategory.map( (group) => html` <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"> ${group.items.map( (t) => html` <button type="button" title=${`${t.mode} · ${t.color} · ${t.font} · r=${t.radius}`} aria-pressed=${activeTheme?.key === t.key ? 'true' : 'false'} class=${[ 'group relative flex flex-col overflow-hidden rounded-lg border text-left transition-[box-shadow,scale]', activeTheme?.key === t.key ? 'ring-ring ring-offset-background border-transparent ring-2 ring-offset-2' : 'hover:scale-[1.03] hover:shadow-md', ].join(' ')} @click=${() => this.applyTheme(t)} > <div class="relative h-14 px-2 py-1.5" style=${`background: ${(TINTS.find((x) => x.key === t.tint) as unknown as Record<string, string>)[t.mode === 'dark' ? 'dark' : 'light']}; color: ${t.mode === 'dark' ? 'oklch(0.985 0 0)' : 'oklch(0.205 0 0)'}`} > <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: ${PRESETS.find((p) => p.key === t.color)?.swatch}`} ></span> </div> <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: ${PRESETS.find((p) => p.key === t.color)?.swatch}; color: oklch(0.985 0 0); border-radius: ${Math.min(t.radius, 0.5)}rem; font-family: ${FONTS.find((f) => f.key === t.font)?.stack}`} > Aa </span> <span class="block h-1 flex-1 rounded-full opacity-30" style="background: currentColor"></span> </div> <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> <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> ${activeTheme?.key === t.key ? icon(Check, 'check', 'text-primary size-3') : nothing} </div> </button> `, )} </div> </div> `, )} ${this.filteredThemes.length === 0 ? html`<div class="text-muted-foreground py-6 text-center text-xs"> No themes match "<span class="font-mono">${this.themeSearch}</span>" </div>` : nothing} </div> </div> <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"> ${modes.map((m) => { const Icon = m.icon const name = m.value === 'light' ? 'sun' : m.value === 'dark' ? 'moon' : 'monitor' return html` <button type="button" aria-pressed=${this.mode === m.value ? 'true' : 'false'} class=${[ 'flex items-center justify-center gap-1.5 rounded px-2 py-1.5 text-xs font-medium transition-colors', this.mode === m.value ? 'bg-background shadow-sm' : 'text-muted-foreground hover:text-foreground', ].join(' ')} @click=${() => this.setMode(m.value)} > ${icon(Icon, name, 'size-3.5')} ${m.label} </button> ` })} </div> </div> <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"> ${PRESETS.map( (p) => html` <button type="button" aria-label=${p.label} aria-pressed=${this.preset === p.key ? 'true' : 'false'} title=${p.label} class=${[ 'group ring-offset-background relative aspect-square rounded-md transition-transform', this.preset === p.key ? 'ring-ring ring-2 ring-offset-2' : 'hover:scale-110', ].join(' ')} style=${`background: ${p.swatch}`} @click=${() => this.setPreset(p.key)} > ${this.preset === p.key ? icon(Check, 'check', 'absolute inset-0 m-auto size-3 text-white mix-blend-difference') : nothing} </button> `, )} <label class=${[ 'relative flex aspect-square cursor-pointer items-center justify-center rounded-md border transition-transform', this.preset === 'custom' ? 'ring-ring ring-offset-background border-transparent ring-2 ring-offset-2' : 'border-dashed hover:scale-110', ].join(' ')} style=${this.preset === 'custom' ? `background: ${this.customColor}` : nothing} aria-label="Custom color" title="Custom color" > <input type="color" .value=${this.customColor} class="sr-only" @change=${(e: Event) => { this.customColor = (e.target as HTMLInputElement).value this.setPreset('custom') }} /> ${this.preset !== 'custom' ? icon(Pipette, 'pipette', 'text-muted-foreground size-3') : icon(Check, 'check', 'size-3 text-white mix-blend-difference')} </label> </div> </div> <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"> ${TINTS.map( (t) => html` <button type="button" aria-pressed=${this.tint === t.key ? 'true' : 'false'} class=${[ 'rounded px-2 py-1.5 text-xs font-medium transition-colors', this.tint === t.key ? 'bg-background shadow-sm' : 'text-muted-foreground hover:text-foreground', ].join(' ')} @click=${() => this.setTint(t.key)} > ${t.label} </button> `, )} </div> </div> <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"> ${FONTS.map( (f) => html` <button type="button" aria-pressed=${this.font === f.key ? 'true' : 'false'} class=${[ 'rounded px-2 py-1.5 text-xs font-medium transition-colors', this.font === f.key ? 'bg-background shadow-sm' : 'text-muted-foreground hover:text-foreground', ].join(' ')} style=${`font-family: ${f.stack}`} @click=${() => this.setFont(f.key)} > ${f.label} </button> `, )} </div> </div> <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">${this.radius.toFixed(2)}rem</span> </div> <uip-slider .value=${[this.radius]} min="0" max="1" step="0.05" @value-change=${(e: CustomEvent<number[]>) => this.setRadius(e.detail[0] ?? this.radius)} ></uip-slider> <div class="text-muted-foreground mt-1 flex justify-between text-xs"> <span>0</span> <span>0.5</span> <span>1</span> </div> </div> <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"> <uip-button size="sm" class="text-xs">Primary</uip-button> <uip-button size="sm" variant="outline" class="text-xs">Outline</uip-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> </uip-sheet-body> <div slot="footer" class="flex w-full gap-2"> <uip-button variant="ghost" size="sm" class="flex-1 gap-1.5" @click=${this.reset}> ${icon(RotateCcw, 'rotate-ccw', 'size-3.5')} Reset </uip-button> <uip-button aria-label="Download attachment" variant="outline" size="sm" class="flex-1 gap-1.5" @click=${this.exportJson}> ${icon(Download, 'download', 'size-3.5')} ${this.exported ? 'Saved' : 'Export'} </uip-button> <uip-button variant="default" size="sm" class="flex-1 gap-1.5" @click=${this.copyCss}> ${this.copied ? icon(Check, 'check', 'size-3.5') : icon(Copy, 'copy', 'size-3.5')} ${this.copied ? 'Copied' : 'Copy CSS'} </uip-button> </div> </uip-sheet> ` } } customElements.get('uip-theme-customize') || customElements.define('uip-theme-customize', UipThemeCustomize) declare global { interface HTMLElementTagNameMap { 'uip-theme-customize': UipThemeCustomize } }
Raw manifest:https://uipkge.dev/r/lit/theme-customize.json