Skip to content

Theme System

This document describes the theme switching and skin preset system for HaloLight.

Theme Modes

ModeDescription
lightLight theme
darkDark theme
systemFollow system

Skin Presets

11 color skins available:

SkinPrimaryUse Case
defaultBlueGeneral
zincGrayMinimalist
slateBlue-grayProfessional
stoneBrown-grayWarm
grayNeutral grayGeneral
neutralBlack & whiteMinimal
redRedAlert
roseRoseFashion
orangeOrangeEnergetic
greenGreenNatural
blueBlueTech
yellowYellowBright
violetVioletElegant

CSS Variables

Color Variable Definition

css
:root {
  --background: 0 0% 100%;
  --foreground: 222.2 84% 4.9%;
  --card: 0 0% 100%;
  --card-foreground: 222.2 84% 4.9%;
  --popover: 0 0% 100%;
  --popover-foreground: 222.2 84% 4.9%;
  --primary: 222.2 47.4% 11.2%;
  --primary-foreground: 210 40% 98%;
  --secondary: 210 40% 96.1%;
  --secondary-foreground: 222.2 47.4% 11.2%;
  --muted: 210 40% 96.1%;
  --muted-foreground: 215.4 16.3% 46.9%;
  --accent: 210 40% 96.1%;
  --accent-foreground: 222.2 47.4% 11.2%;
  --destructive: 0 84.2% 60.2%;
  --destructive-foreground: 210 40% 98%;
  --border: 214.3 31.8% 91.4%;
  --input: 214.3 31.8% 91.4%;
  --ring: 222.2 84% 4.9%;
  --radius: 0.5rem;
}

.dark {
  --background: 222.2 84% 4.9%;
  --foreground: 210 40% 98%;
  /* ... */
}

Skin Variables

css
[data-skin="rose"] {
  --primary: 346.8 77.2% 49.8%;
  --primary-foreground: 355.7 100% 97.3%;
}

[data-skin="green"] {
  --primary: 142.1 76.2% 36.3%;
  --primary-foreground: 355.7 100% 97.3%;
}

Theme Switching Implementation

React Context

tsx
interface ThemeContextValue {
  theme: 'light' | 'dark' | 'system'
  skin: SkinPreset
  setTheme: (theme: string) => void
  setSkin: (skin: SkinPreset) => void
}

export function ThemeProvider({ children }: { children: ReactNode }) {
  const [theme, setTheme] = useState('system')
  const [skin, setSkin] = useState<SkinPreset>('default')

  useEffect(() => {
    const root = document.documentElement
    root.classList.remove('light', 'dark')

    if (theme === 'system') {
      const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches
        ? 'dark'
        : 'light'
      root.classList.add(systemTheme)
    } else {
      root.classList.add(theme)
    }

    root.setAttribute('data-skin', skin)
  }, [theme, skin])

  return (
    <ThemeContext.Provider value={{ theme, skin, setTheme, setSkin }}>
      {children}
    </ThemeContext.Provider>
  )
}

Vue Composable

ts
export function useTheme() {
  const theme = ref<'light' | 'dark' | 'system'>('system')
  const skin = ref<SkinPreset>('default')

  const actualTheme = computed(() => {
    if (theme.value === 'system') {
      return window.matchMedia('(prefers-color-scheme: dark)').matches
        ? 'dark'
        : 'light'
    }
    return theme.value
  })

  watch([theme, skin], () => {
    document.documentElement.classList.remove('light', 'dark')
    document.documentElement.classList.add(actualTheme.value)
    document.documentElement.setAttribute('data-skin', skin.value)
  }, { immediate: true })

  return { theme, skin, actualTheme }
}

View Transitions API

Theme Switch Animation (Vue)

ts
async function toggleTheme() {
  if (!document.startViewTransition) {
    theme.value = theme.value === 'dark' ? 'light' : 'dark'
    return
  }

  await document.startViewTransition(() => {
    theme.value = theme.value === 'dark' ? 'light' : 'dark'
  }).ready

  // Circular expand animation
  const { clientX, clientY } = event
  const radius = Math.hypot(
    Math.max(clientX, window.innerWidth - clientX),
    Math.max(clientY, window.innerHeight - clientY)
  )

  document.documentElement.animate(
    {
      clipPath: [
        `circle(0px at ${clientX}px ${clientY}px)`,
        `circle(${radius}px at ${clientX}px ${clientY}px)`,
      ],
    },
    {
      duration: 500,
      easing: 'ease-in-out',
      pseudoElement: '::view-transition-new(root)',
    }
  )
}

CSS Configuration

css
::view-transition-old(root),
::view-transition-new(root) {
  animation: none;
  mix-blend-mode: normal;
}

::view-transition-old(root) {
  z-index: 1;
}

::view-transition-new(root) {
  z-index: 9999;
}

Theme Selector Component

tsx
function ThemeSelector() {
  const { theme, setTheme } = useTheme()

  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <Button variant="ghost" size="icon">
          <Sun className="h-4 w-4 rotate-0 scale-100 dark:-rotate-90 dark:scale-0" />
          <Moon className="absolute h-4 w-4 rotate-90 scale-0 dark:rotate-0 dark:scale-100" />
        </Button>
      </DropdownMenuTrigger>
      <DropdownMenuContent>
        <DropdownMenuItem onClick={() => setTheme('light')}>
          <Sun className="mr-2 h-4 w-4" /> Light
        </DropdownMenuItem>
        <DropdownMenuItem onClick={() => setTheme('dark')}>
          <Moon className="mr-2 h-4 w-4" /> Dark
        </DropdownMenuItem>
        <DropdownMenuItem onClick={() => setTheme('system')}>
          <Monitor className="mr-2 h-4 w-4" /> System
        </DropdownMenuItem>
      </DropdownMenuContent>
    </DropdownMenu>
  )
}

Skin Selector

tsx
function SkinSelector() {
  const { skin, setSkin } = useTheme()

  const skins: SkinPreset[] = [
    'default', 'zinc', 'slate', 'stone', 'gray',
    'neutral', 'red', 'rose', 'orange', 'green',
    'blue', 'yellow', 'violet'
  ]

  return (
    <div className="grid grid-cols-5 gap-2">
      {skins.map((s) => (
        <button
          key={s}
          onClick={() => setSkin(s)}
          className={cn(
            'h-8 w-8 rounded-full border-2',
            skin === s ? 'border-primary' : 'border-transparent'
          )}
          style={{ backgroundColor: `hsl(var(--skin-${s}))` }}
        />
      ))}
    </div>
  )
}

ECharts Theme Adaptation

ts
const echartTheme = computed(() => ({
  backgroundColor: 'transparent',
  textStyle: {
    color: actualTheme.value === 'dark' ? '#e5e5e5' : '#333',
  },
  title: {
    textStyle: {
      color: actualTheme.value === 'dark' ? '#fff' : '#333',
    },
  },
  legend: {
    textStyle: {
      color: actualTheme.value === 'dark' ? '#e5e5e5' : '#333',
    },
  },
  xAxis: {
    axisLine: { lineStyle: { color: actualTheme.value === 'dark' ? '#444' : '#ccc' } },
    splitLine: { lineStyle: { color: actualTheme.value === 'dark' ? '#333' : '#eee' } },
  },
  yAxis: {
    axisLine: { lineStyle: { color: actualTheme.value === 'dark' ? '#444' : '#ccc' } },
    splitLine: { lineStyle: { color: actualTheme.value === 'dark' ? '#333' : '#eee' } },
  },
}))