v1.0

ThemeSettings

Preview

Basic

Loading…

Preview

Code

ts
import ThemeSettings from '@/components/layout/ThemeSettings';

src/components/layout/ThemeSettings.tsx

AI prompt

text
Build an appearance settings dropdown (light/dark theme and row density) in React + TypeScript + Tailwind CSS.

## Look
- Trigger: `p-2 rounded-lg text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800`, a 16px lucide `SlidersHorizontal`, title and aria-label "Appearance"; while open it keeps `bg-slate-100 text-slate-600` (dark `bg-slate-800 text-slate-200`).
- Panel: floating surface, `absolute right-0 mt-2 w-60 p-3 z-50 space-y-3`.
- Theme row, `flex items-center justify-between`: label "Theme" with a 14px `Sun` (`flex items-center gap-2 text-xs font-medium text-slate-600 dark:text-slate-300`); at the right a two-button track `flex rounded-lg bg-slate-100 dark:bg-slate-800 p-0.5`. Each button `p-1.5 rounded-md` with a 14px icon: `Sun` (aria-label "Light theme") and `Moon` ("Dark theme"). Selected: `bg-white dark:bg-slate-700 shadow-sm` with the icon in amber-500 for Sun, indigo-500 (dark indigo-300) for Moon. Unselected: `text-slate-400 hover:text-slate-600 dark:hover:text-slate-200`.
- Density block, `space-y-1.5`: label "Density" with a 14px `Type` icon, then a `grid grid-cols-3 gap-1 rounded-lg bg-slate-100 dark:bg-slate-800 p-0.5` of compact / standard / comfortable, each `px-2 py-1.5 text-[10px] font-medium rounded-md capitalize truncate`; selected `bg-white dark:bg-slate-700 shadow-sm text-slate-800 dark:text-white`, others `text-slate-500 hover:text-slate-700 dark:hover:text-slate-200`.

## Theme state (build it too)
A small React context with a `ThemeProvider` and `useTheme()` hook exposing `theme: 'light' | 'dark'`, `setTheme`, `density: 'compact' | 'standard' | 'comfortable'`, `setDensity`:
- On mount, read `localStorage` keys `app.theme` and `app.density`. With no stored theme, follow the OS `prefers-color-scheme` ONCE, then persist whatever the user picks.
- Only after that first read (a `mounted` flag), apply and persist on every change: toggle the `dark` class on `<html>`; set the root font-size (compact 13px, standard 14px, comfortable 16px) and a `data-density` attribute on `<html>` that global CSS can key table row heights off. Writing before the read would overwrite the stored preference with the default.
- Pair it with an inline pre-paint script in `<head>` that applies the same keys before hydration, so dark-mode users don't see a white flash.

## Demo
The trigger alone in a header row; open it and switch theme and density.

## House style (applies to everything above)
- Stack: React 19 + TypeScript + Tailwind CSS v4, icons from lucide-react. One self-contained file; default-export the component and named-export its types. `'use client'` if it has state, refs or handlers.
- Font Inter; palette indigo on slate. Primary accent indigo-600 (hover indigo-700, dark mode indigo-400). Body text slate-700 / dark slate-200; secondary slate-500 / dark slate-400.
- Dark mode is a `.dark` class on <html> (not prefers-color-scheme). Every colour needs its `dark:` pair.
- Compact admin scale: text-xs (12px) for controls and body, 10–11px for meta, rounded-lg (8px) controls, rounded-2xl (16px) cards.
- Card surface ("panel"): `bg-white/60 dark:bg-slate-800/60 backdrop-blur-xl border border-white/60 dark:border-slate-700/60 rounded-2xl shadow-lg`, on a soft slate gradient page background.
- Floating surfaces (dropdowns, popovers, menus) are OPAQUE: `bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-lg`, no backdrop blur (it creates a stacking context that traps the popover's z-index). In-flow popovers are z-50; portalled overlays z-200.
- Text inputs and select triggers: `w-full px-3 py-2 text-xs rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-900/60 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500`.
- Field labels: 11px semibold slate-600. Section titles: 10px semibold uppercase wide-tracking slate-500.
- Primary button: indigo-600 fill, white 12px semibold text, rounded-lg, px-3 py-2, disabled at 50% opacity. Ghost button: slate-600 text, hover slate-100.
- Popovers close on outside click AND on Escape (listen to both; include the portalled panel's element in the outside-click check).
- Don't nest scroll containers around popovers: an ancestor with overflow hidden/auto clips an absolutely-positioned dropdown. Portal the panel to <body> when it must escape a scroller, and reposition it on scroll and resize.
- Accessible by default: visible focus rings, keyboard support that matches the WAI-ARIA pattern for the widget, `aria-label` on icon-only buttons, `min-w-0` so text truncates instead of overflowing.

Source

tsx
'use client';

/* Origin: bonus-adjustment (96S2), verbatim. */

import { useRef, useState } from 'react';
import { Moon, Sun, Type, SlidersHorizontal } from 'lucide-react';
import { cn } from '@/lib/cn';
import { useDismiss } from '@/lib/use-dismiss';
import { useTheme, type Density } from '@/contexts/ThemeContext';

const DENSITIES: readonly Density[] = ['compact', 'standard', 'comfortable'];

export default function ThemeSettings() {
  const [open, setOpen] = useState(false);
  const ref = useRef<HTMLDivElement>(null);
  const { theme, setTheme, density, setDensity } = useTheme();

  useDismiss(ref, open, () => setOpen(false));

  return (
    <div className="relative" ref={ref}>
      <button
        type="button"
        onClick={() => setOpen((o) => !o)}
        title="Appearance"
        aria-label="Appearance"
        className={cn(
          'p-2 rounded-lg text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors',
          open && 'bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-200',
        )}
      >
        <SlidersHorizontal className="w-4 h-4" />
      </button>

      {/* `panel-solid` is `.panel` without the translucency: this floats over
          page content rather than sitting on the gradient ground, so the
          frosted default shows whatever is behind it straight through the
          menu. Shared with the other two header dropdowns. */}
      {open && (
        <div className="absolute right-0 mt-2 w-60 panel panel-solid p-3 z-50 space-y-3">
          <div className="flex items-center justify-between">
            <span className="flex items-center gap-2 text-xs font-medium text-slate-600 dark:text-slate-300">
              <Sun className="w-3.5 h-3.5" /> Theme
            </span>
            <div className="flex bg-slate-100 dark:bg-slate-800 rounded-lg p-0.5">
              <button
                type="button"
                onClick={() => setTheme('light')}
                aria-label="Light theme"
                className={cn(
                  'p-1.5 rounded-md transition-colors',
                  theme === 'light'
                    ? 'bg-white dark:bg-slate-700 shadow-sm text-amber-500'
                    : 'text-slate-400 hover:text-slate-600 dark:hover:text-slate-200',
                )}
              >
                <Sun className="w-3.5 h-3.5" />
              </button>
              <button
                type="button"
                onClick={() => setTheme('dark')}
                aria-label="Dark theme"
                className={cn(
                  'p-1.5 rounded-md transition-colors',
                  theme === 'dark'
                    ? 'bg-white dark:bg-slate-700 shadow-sm text-indigo-500 dark:text-indigo-300'
                    : 'text-slate-400 hover:text-slate-600 dark:hover:text-slate-200',
                )}
              >
                <Moon className="w-3.5 h-3.5" />
              </button>
            </div>
          </div>

          <div className="space-y-1.5">
            <span className="flex items-center gap-2 text-xs font-medium text-slate-600 dark:text-slate-300">
              <Type className="w-3.5 h-3.5" /> Density
            </span>
            <div className="grid grid-cols-3 gap-1 bg-slate-100 dark:bg-slate-800 rounded-lg p-0.5">
              {DENSITIES.map((d) => (
                <button
                  key={d}
                  type="button"
                  onClick={() => setDensity(d)}
                  className={cn(
                    'px-2 py-1.5 text-[10px] font-medium rounded-md capitalize truncate transition-colors',
                    density === d
                      ? 'bg-white dark:bg-slate-700 shadow-sm text-slate-800 dark:text-white'
                      : 'text-slate-500 hover:text-slate-700 dark:hover:text-slate-200',
                  )}
                >
                  {d}
                </button>
              ))}
            </div>
          </div>
        </div>
      )}
    </div>
  );
}