Theme Customize
blocklayoutCompact theme customization popover. Light/Dark/System mode (via the uipkge-theme cookie), 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 uipkge-ng@latest add theme-customize$npx uipkge-ng@latest add theme-customize$yarn dlx uipkge-ng@latest add theme-customize$bunx uipkge-ng@latest add theme-customizeManifest:
https://uipkge.dev/r/angular/theme-customize.jsonInstalls to:src/app/components/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.
ColorPresetinterface ColorPreset {
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: ThemeCategoryKey
mode: CustomizeMode
color: PresetKey
tint: TintKey
font: FontKey
radius: number
}npm dependencies
Files installed (2)
src/app/components/blocks/theme-customize/theme-customize.component.ts36.6 kB
// Compact theme customization sheet: Light/Dark/System mode (uipkge-theme cookie), // curated color presets overwriting --primary / --primary-foreground / --ring, surface tint, // font, and a radius slider bound to --radius. Persists to localStorage with one-click // Copy CSS. Port of the Vue/React block 1:1. import { ChangeDetectionStrategy, Component, Input, OnDestroy, OnInit, computed, signal } from '@angular/core' import { Check, Copy, Download, LucideAngularModule, Monitor, Moon, Palette, Pipette, RotateCcw, Search, Shuffle, Sun, } from 'lucide-angular' import { cn } from '@/lib/utils' import { UiButtonComponent } from '@/ui/button/button.component' import { UiSheetComponent, UiSheetContentComponent, UiSheetDescriptionComponent, UiSheetHeaderComponent, UiSheetTitleComponent, } from '@/ui/sheet/sheet.component' import { UiSliderComponent } from '@/ui/slider/slider.component' export type CustomizeMode = 'light' | 'dark' | 'system' export type PresetKey = | 'neutral' | 'zinc' | 'slate' | 'stone' | 'rose' | 'red' | 'orange' | 'amber' | 'yellow' | 'lime' | 'emerald' | 'teal' | 'cyan' | 'sky' | 'blue' | 'indigo' | 'violet' | 'purple' | 'fuchsia' | 'pink' | 'custom' export 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' export type FontKey = 'sans' | 'serif' | 'mono' export type TintKey = 'pure' | 'cool' | 'warm' export type ThemeCategoryKey = 'featured' | 'editorial' | 'bold' | 'cool' | 'soft' export interface ColorPreset { key: PresetKey label: string swatch: string light: { primary: string; primaryForeground: string; ring: string } dark: { primary: string; primaryForeground: string; ring: string } } export function mkPreset( key: PresetKey, label: string, hue: number, l = 0.6, c = 0.2, fg = 'oklch(0.985 0 0)', ): ColorPreset { const swatch = `oklch(${l} ${c} ${hue})` return { key, label, swatch, light: { primary: swatch, primaryForeground: fg, ring: swatch }, dark: { primary: swatch, primaryForeground: fg, ring: swatch }, } } export const COLOR_PRESETS: ColorPreset[] = [ { 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)', }, }, 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)'), 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)'), 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), 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), ] export interface ThemePreset { key: ThemeKey label: string category: ThemeCategoryKey mode: CustomizeMode color: PresetKey tint: TintKey font: FontKey radius: number } export const CATEGORY_LABELS: Record<ThemeCategoryKey, string> = { featured: 'Featured', editorial: 'Editorial', bold: 'Bold', cool: 'Cool', soft: 'Soft', } export 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, }, ] export 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' }, ] export 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 COOKIE_KEY = 'uipkge-theme' function readModeCookie(): CustomizeMode { if (typeof document === 'undefined') return 'system' const m = document.cookie.match(/(?:^|; )uipkge-theme=([^;]+)/) const v = m ? decodeURIComponent(m[1]) : 'system' return v === 'light' || v === 'dark' || v === 'system' ? v : 'system' } @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-theme-customize, [ui-theme-customize]', standalone: true, // React renders the trigger itself: the host stays out of layout and `class` goes to the root. host: { '[attr.class]': '"contents"' }, imports: [ LucideAngularModule, UiButtonComponent, UiSheetComponent, UiSheetContentComponent, UiSheetDescriptionComponent, UiSheetHeaderComponent, UiSheetTitleComponent, UiSliderComponent, ], template: ` <div data-slot="theme-customize" [class]="rootClass"> <button ui-button variant="outline" size="sm" class="gap-2" (click)="sheetOpen.set(true)"> <lucide-icon [img]="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> <div ui-sheet [open]="sheetOpen()" (openChange)="sheetOpen.set($event)"> <div ui-sheet-content side="right" class="flex w-full flex-col gap-0 p-0 sm:max-w-md"> <div ui-sheet-header class="border-b px-6 py-4"> <div class="flex items-center justify-between gap-3"> <h2 ui-sheet-title class="flex items-center gap-2 text-base"> <lucide-icon [img]="Palette" class="size-4" /> Customize </h2> @if (activeTheme(); as active) { <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]="swatchFor(active.color)"></span> {{ active.label }} </span> } @else { <span class="text-muted-foreground bg-muted/50 rounded-full px-2 py-0.5 text-xs font-medium"> Custom </span> } </div> <p ui-sheet-description class="text-xs"> Live-edit tokens. Changes write to <code class="font-mono">:root</code> and persist locally. </p> <div class="mt-2 flex gap-2"> <button ui-button variant="outline" size="xs" class="gap-1.5" (click)="applyRandomTheme()"> <lucide-icon [img]="Shuffle" class="size-3" /> Surprise me </button> </div> </div> <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()) { <span> / {{ themesCount }} </span> } presets </span> </div> <div class="relative mb-3"> <input [value]="themeSearch()" (input)="themeSearch.set($any($event.target).value)" 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" /> <lucide-icon [img]="SearchIcon" class="text-muted-foreground absolute top-1/2 left-2 size-3.5 -translate-y-1/2" /> </div> <div class="space-y-4"> @for (group of themesByCategory(); track 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"> @for (t of group.items; track t.key) { <button type="button" [title]="t.mode + ' · ' + t.color + ' · ' + t.font + ' · r=' + t.radius" [attr.aria-pressed]="activeTheme()?.key === t.key" [class]="themeCardClass(t.key)" (click)="applyTheme(t)" > <div class="relative h-14 px-2 py-1.5" [style.background]="tintSurface(t.tint, t.mode)" [style.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]="swatchFor(t.color)" ></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]="swatchFor(t.color)" style="color: oklch(0.985 0 0)" [style.border-radius]="previewRadius(t.radius)" [style.font-family]="fontStack(t.font)" >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> @if (activeTheme()?.key === t.key) { <lucide-icon [img]="Check" class="text-primary size-3" /> } </div> </button> } </div> </div> } @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> } </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"> @for (m of modes; track m.value) { <button type="button" [attr.aria-pressed]="mode() === m.value" [class]="segmentClass(mode() === m.value)" (click)="setMode(m.value)" > <lucide-icon [img]="m.icon" class="size-3.5" /> {{ m.label }} </button> } </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">{{ presetsCount }} options</span> </div> <div class="grid grid-cols-10 gap-1.5"> @for (p of presets; track p.key) { <button type="button" [attr.aria-label]="p.label" [attr.aria-pressed]="preset() === p.key" [title]="p.label" [class]="swatchClass(preset() === p.key)" [style.background]="p.swatch" (click)="setPreset(p.key)" > @if (preset() === p.key) { <lucide-icon [img]="Check" class="absolute inset-0 m-auto size-3 text-white mix-blend-difference" /> } </button> } <label [class]="customSlotClass()" [style.background]="preset() === 'custom' ? customColor() : null" aria-label="Custom color" title="Custom color" > <input type="color" [value]="customColor()" class="sr-only" (input)="onCustomColor($any($event.target).value)" /> @if (preset() !== 'custom') { <lucide-icon [img]="Pipette" class="text-muted-foreground size-3" /> } @else { <lucide-icon [img]="Check" class="size-3 text-white mix-blend-difference" /> } </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"> @for (t of tints; track t.key) { <button type="button" [attr.aria-pressed]="tint() === t.key" [class]="segmentClass(tint() === t.key)" (click)="setTint(t.key)" > {{ t.label }} </button> } </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"> @for (f of fonts; track f.key) { <button type="button" [attr.aria-pressed]="font() === f.key" [class]="segmentClass(font() === f.key)" [style.font-family]="f.stack" (click)="setFont(f.key)" > {{ f.label }} </button> } </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> <ui-slider [value]="[radius()]" (valueChange)="onRadiusSlider($event)" [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 ui-button size="sm" class="text-xs">Primary</button> <button ui-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 ui-button variant="ghost" size="sm" class="flex-1 gap-1.5" (click)="reset()"> <lucide-icon [img]="RotateCcw" class="size-3.5" /> Reset </button> <button ui-button aria-label="Download attachment" variant="outline" size="sm" class="flex-1 gap-1.5" (click)="exportJson()" > <lucide-icon [img]="Download" class="size-3.5" /> {{ exported() ? 'Saved' : 'Export' }} </button> <button ui-button variant="default" size="sm" class="flex-1 gap-1.5" (click)="copyCss()"> @if (copied()) { <lucide-icon [img]="Check" class="size-3.5" /> } @else { <lucide-icon [img]="Copy" class="size-3.5" /> } {{ copied() ? 'Copied' : 'Copy CSS' }} </button> </div> </div> </div> </div> `, }) export class UiThemeCustomizeComponent implements OnInit, OnDestroy { protected readonly Check = Check protected readonly Copy = Copy protected readonly Download = Download protected readonly Palette = Palette protected readonly Pipette = Pipette protected readonly RotateCcw = RotateCcw protected readonly Shuffle = Shuffle protected readonly presets = COLOR_PRESETS protected readonly presetsCount = COLOR_PRESETS.length protected readonly themesCount = THEMES.length protected readonly fonts = FONTS protected readonly tints = TINTS protected readonly modes: { value: CustomizeMode; label: string; icon: typeof Sun }[] = [ { value: 'light', label: 'Light', icon: Sun }, { value: 'dark', label: 'Dark', icon: Moon }, { value: 'system', label: 'System', icon: Monitor }, ] /** Search glyph for the theme filter input. */ protected readonly SearchIcon = Search private copyTimer: ReturnType<typeof setTimeout> | undefined private exportTimer: ReturnType<typeof setTimeout> | undefined readonly mode = signal<CustomizeMode>('system') readonly preset = signal<PresetKey>('neutral') readonly customColor = signal('#ec4899') readonly font = signal<FontKey>('sans') readonly tint = signal<TintKey>('pure') readonly radius = signal(0.3) readonly copied = signal(false) readonly exported = signal(false) readonly themeSearch = signal('') readonly sheetOpen = signal(false) @Input('class') className?: string readonly activeTheme = computed<ThemePreset | null>( () => THEMES.find( (t) => t.mode === this.mode() && t.color === this.preset() && t.tint === this.tint() && t.font === this.font(), ) ?? null, ) readonly filteredThemes = computed(() => { const q = this.themeSearch().trim().toLowerCase() if (!q) return THEMES return THEMES.filter((t) => t.label.toLowerCase().includes(q) || t.key.includes(q)) }) readonly themesByCategory = computed<{ key: ThemeCategoryKey; label: string; items: ThemePreset[] }[]>(() => { const order: ThemeCategoryKey[] = ['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 onKeydown = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'j') { e.preventDefault() this.sheetOpen.update((v) => !v) } } ngOnInit(): void { this.load() this.mode.set(readModeCookie()) this.applyAll() if (typeof window !== 'undefined') window.addEventListener('keydown', this.onKeydown) } ngOnDestroy(): void { if (typeof window !== 'undefined') window.removeEventListener('keydown', this.onKeydown) clearTimeout(this.copyTimer) clearTimeout(this.exportTimer) } get rootClass(): string { return cn(this.className) } swatchFor(key: PresetKey): string { return COLOR_PRESETS.find((p) => p.key === key)?.swatch ?? '#ec4899' } fontStack(key: FontKey): string { return FONTS.find((f) => f.key === key)?.stack ?? FONTS[0]!.stack } tintSurface(key: TintKey, mode: CustomizeMode): string { const t = TINTS.find((x) => x.key === key) ?? TINTS[0]! return mode === 'dark' ? t.dark : t.light } previewRadius(r: number): string { return `${Math.min(r, 0.5)}rem` } segmentClass(active: boolean): string { return cn( 'flex items-center justify-center gap-1.5 rounded px-2 py-1.5 text-xs font-medium transition-colors', active ? 'bg-background shadow-sm' : 'text-muted-foreground hover:text-foreground', ) } themeCardClass(key: ThemeKey): string { return cn( 'group relative flex flex-col overflow-hidden rounded-lg border text-left transition-[box-shadow,scale]', this.activeTheme()?.key === key ? 'ring-ring ring-offset-background border-transparent ring-2 ring-offset-2' : 'hover:scale-[1.03] hover:shadow-md', ) } swatchClass(active: boolean): string { return cn( 'group ring-offset-background relative aspect-square rounded-md transition-transform', active ? 'ring-ring ring-2 ring-offset-2' : 'hover:scale-110', ) } customSlotClass(): string { return cn( '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', ) } isDarkActive(): boolean { if (typeof document === 'undefined') return false return document.documentElement.classList.contains('dark') } applyPreset(key: PresetKey): void { if (typeof document === 'undefined') return 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 = COLOR_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) } applyTint(key: TintKey): void { if (typeof document === 'undefined') return const t = TINTS.find((x) => x.key === key) if (!t) return document.documentElement.style.setProperty('--background', this.isDarkActive() ? t.dark : t.light) } applyFont(key: FontKey): void { if (typeof document === 'undefined') return const f = FONTS.find((x) => x.key === key) if (!f) return document.documentElement.style.setProperty('--font-sans', f.stack) } applyRadius(rem: number): void { if (typeof document === 'undefined') return document.documentElement.style.setProperty('--radius', `${rem}rem`) } applyMode(mode: CustomizeMode): void { if (typeof document === 'undefined') return try { document.cookie = `${COOKIE_KEY}=${encodeURIComponent(mode)}; path=/; max-age=${60 * 60 * 24 * 365}; samesite=lax` } catch { /* ignore */ } const dark = mode === 'dark' || (mode === 'system' && !!window.matchMedia?.('(prefers-color-scheme: dark)').matches) document.documentElement.classList.toggle('dark', dark) // Re-resolve light/dark token axes after the class flip (mirrors the Vue theme watcher). setTimeout(() => { this.applyPreset(this.preset()) this.applyTint(this.tint()) }, 0) } save(): void { try { localStorage.setItem( STORAGE_KEY, JSON.stringify({ preset: this.preset(), customColor: this.customColor(), font: this.font(), tint: this.tint(), radius: this.radius(), }), ) } catch { /* ignore */ } } load(): void { try { const raw = localStorage.getItem(STORAGE_KEY) if (!raw) return const parsed = JSON.parse(raw) if (parsed.preset) this.preset.set(parsed.preset) if (parsed.customColor) this.customColor.set(parsed.customColor) if (parsed.font) this.font.set(parsed.font) if (parsed.tint) this.tint.set(parsed.tint) if (typeof parsed.radius === 'number') this.radius.set(parsed.radius) } catch { /* ignore */ } } applyAll(): void { this.applyPreset(this.preset()) this.applyTint(this.tint()) this.applyFont(this.font()) this.applyRadius(this.radius()) } setMode(v: CustomizeMode): void { this.mode.set(v) this.applyMode(v) } setPreset(v: PresetKey): void { this.preset.set(v) this.applyPreset(v) this.save() } onCustomColor(v: string): void { this.customColor.set(v) this.preset.set('custom') this.applyPreset('custom') this.save() } setTint(v: TintKey): void { this.tint.set(v) this.applyTint(v) this.save() } setFont(v: FontKey): void { this.font.set(v) this.applyFont(v) this.save() } onRadiusSlider(v: number[]): void { if (typeof v[0] === 'number') { this.radius.set(v[0]) this.applyRadius(v[0]) this.save() } } applyTheme(t: ThemePreset): void { // Themes drive mode / color / tint / font. Radius stays user-controlled (default 0.3). this.setMode(t.mode) this.preset.set(t.color) this.tint.set(t.tint) this.font.set(t.font) this.applyPreset(t.color) this.applyTint(t.tint) this.applyFont(t.font) this.save() } applyRandomTheme(): void { const active = this.activeTheme()?.key const candidates = THEMES.filter((t) => t.key !== active) const pick = candidates[Math.floor(Math.random() * candidates.length)] if (pick) this.applyTheme(pick) } reset(): void { this.preset.set('neutral') this.customColor.set('#ec4899') this.font.set('sans') this.tint.set('pure') this.radius.set(0.3) if (typeof document !== 'undefined') { 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 */ } } buildCss(): string { const found = COLOR_PRESETS.find((p) => p.key === this.preset()) ?? COLOR_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}; }` } async copyCss(): Promise<void> { try { await navigator.clipboard.writeText(this.buildCss()) this.copied.set(true) clearTimeout(this.copyTimer) this.copyTimer = setTimeout(() => this.copied.set(false), 1600) } catch { /* ignore */ } } exportJson(): void { 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.set(true) clearTimeout(this.exportTimer) this.exportTimer = setTimeout(() => this.exported.set(false), 1600) } }src/app/components/blocks/theme-customize/index.ts0.3 kB
export { UiThemeCustomizeComponent, type ColorPreset, type CustomizeMode, type FontKey, type PresetKey, type ThemeCategoryKey, type ThemeKey, type ThemePreset, type TintKey, COLOR_PRESETS, CATEGORY_LABELS, FONTS, THEMES, TINTS, mkPreset, } from './theme-customize.component'
Raw manifest:https://uipkge.dev/r/angular/theme-customize.json