Light Scenes
Smart lights for one room: scene presets, per-lamp switches and dimmers, and an all-off that remembers the last scene.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/light-scenes.json'use client'
import { useState } from 'react'
import './light-scenes.css'
export interface Lamp { id: string; name: string; level: number }
export interface LightScenesProps {
lamps: Lamp[]
scenes: Record<string, number[]>
room?: string
onChange?: (levels: number[]) => void
fill?: 'dark' | 'light'
className?: string
}
/** Room lighting with scenes, per-lamp dimmers and all-off memory. */
export function LightScenes({ lamps, scenes, room = 'Living room', onChange, fill = 'dark', className }: LightScenesProps) {
const [lv, setLv] = useState(lamps.map((l) => l.level))
const [mem, setMem] = useState<number[] | null>(null)
const set = (n: number[]) => { setLv(n); onChange?.(n) }
const on = lv.some((x) => x > 0)
const scene = Object.entries(scenes).find(([, s]) => s.every((v, i) => v === lv[i]))?.[0] ?? (on ? 'Custom' : 'Off')
return (
<div className={['sd-lsc', className].filter(Boolean).join(' ')} data-fill={fill}>
<div className="sd-lsc__h">
<div><b>{room}</b><small aria-live="polite">{on ? `${lv.filter((x) => x > 0).length} of ${lamps.length} on · ${scene}` : 'All off'}</small></div>
<button type="button" className="sd-lsc__pw" aria-pressed={on} aria-label={on ? 'Turn all off' : 'Turn back on'} onClick={() => { if (on) { setMem(lv); set(lv.map(() => 0)) } else set(mem ?? Object.values(scenes)[0]) }}>
<svg viewBox="0 0 24 24" aria-hidden><path d="M12 3v8M6.3 6.8a8 8 0 1 0 11.4 0" /></svg>
</button>
</div>
<div className="sd-lsc__sc" role="radiogroup" aria-label="Scenes">
{Object.entries(scenes).map(([name, s]) => <button key={name} type="button" role="radio" aria-checked={scene === name} onClick={() => set(s)}><i aria-hidden>{s.map((v, i) => <b key={i} style={{ height: Math.max(2, v * 0.14), opacity: v ? 1 : 0.3 }} />)}</i>{name}</button>)}
</div>
<ul>
{lamps.map((l, i) => (
<li key={l.id}>
<span>{l.name}<small>{lv[i] ? `${lv[i]}%` : 'Off'}</small></span>
<button type="button" role="switch" aria-checked={lv[i] > 0} aria-label={l.name} className="sd-lsc__sw" onClick={() => set(lv.map((v, j) => (j === i ? (v ? 0 : 70) : v)))} />
<input type="range" min={0} max={100} step={5} value={lv[i]} aria-label={`${l.name} brightness`} aria-valuetext={`${lv[i]}%`} style={{ ['--p' as string]: `${lv[i]}%` }} onChange={(e) => set(lv.map((v, j) => (j === i ? Number(e.target.value) : v)))} />
</li>
))}
</ul>
</div>
)
}.sd-lsc { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-lsc[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-lsc :where(button) { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-lsc :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-lsc .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-lsc { width: min(100%, 320px); padding: 14px; grid-template-columns: minmax(0, 1fr); border-radius: 18px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); display: grid; gap: 12px; }
.sd-lsc__h { display: flex; align-items: center; gap: 10px; }
.sd-lsc__h > div { flex: 1; }
.sd-lsc__h b { display: block; font-size: 15px; }
.sd-lsc__h small { color: var(--mute); font-size: 12px; }
.sd-lsc__pw { width: 44px; height: 44px; border-radius: 50%; display: grid; place-items: center; box-shadow: inset 0 0 0 1px var(--line); }
.sd-lsc__pw[aria-pressed='true'] { background: var(--strong) !important; color: var(--on) !important; box-shadow: none; }
.sd-lsc__pw svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; }
.sd-lsc__sc { display: grid; grid-template-columns: repeat(4, 1fr); gap: 5px; }
.sd-lsc__sc button { display: grid; justify-items: center; gap: 5px; padding: 9px 0 7px; border-radius: 12px; font-size: 11.5px; box-shadow: inset 0 0 0 1px var(--line); }
.sd-lsc__sc button[aria-checked='true'] { box-shadow: inset 0 0 0 1.5px var(--fg); font-weight: 600; }
.sd-lsc__sc button i { display: flex; gap: 2px; align-items: flex-end; height: 14px; }
.sd-lsc__sc button i b { width: 4px; border-radius: 1px; background: currentColor; }
.sd-lsc ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
.sd-lsc li { display: grid; grid-template-columns: 1fr auto; gap: 8px 10px; align-items: center; padding: 10px 12px; border-radius: 12px; background: var(--soft); }
.sd-lsc li span { font-size: 13px; font-weight: 600; }
.sd-lsc li span small { font-size: 11px; font-weight: 400; color: var(--mute); margin-left: 6px; font-variant-numeric: tabular-nums; }
.sd-lsc input[type='range'] { grid-column: span 2; width: 100%; height: 6px; margin: 0; appearance: none; border-radius: 3px; background: linear-gradient(var(--fg), var(--fg)) 0 / var(--p) 100% no-repeat, var(--line); outline-offset: 4px; }
.sd-lsc input[type='range']:disabled { opacity: 0.35; }
.sd-lsc input[type='range']::-webkit-slider-thumb { appearance: none; width: 18px; height: 18px; border-radius: 50%; background: var(--bg); box-shadow: inset 0 0 0 2px var(--fg); }
.sd-lsc input[type='range']::-moz-range-thumb { width: 16px; height: 16px; border: 2px solid var(--fg); border-radius: 50%; background: var(--bg); }
.sd-lsc__sw { position: relative; width: 40px; height: 24px; flex: none; border-radius: 99px; background: var(--line) !important; transition: background-color 160ms; }
.sd-lsc__sw::after { content: ''; position: absolute; top: 3px; left: 3px; width: 18px; height: 18px; border-radius: 50%; background: var(--fg); transition: transform 200ms cubic-bezier(0.3, 1.3, 0.5, 1); }
.sd-lsc__sw[aria-checked='true'] { background: var(--fg) !important; }
.sd-lsc__sw[aria-checked='true']::after { transform: translateX(16px); background: var(--bg); }
@media (prefers-reduced-motion: reduce) { .sd-lsc *, .sd-lsc *::before, .sd-lsc *::after { animation: none !important; transition: none !important; } }Overview
Scenes set every lamp at once; touching a single lamp afterwards marks the scene as Custom. Brightness is a slider per lamp with its value in text.
All off stores the current levels, so turning the room back on returns to exactly where it was.
When to use
- Smart-home apps, hotel room controls.
When not to use
- Colour-temperature control: add a warm-to-cool slider.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/light-scenes.json$
npx shadcn@latest add https://sundaeui.dev/r/light-scenes.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/light-scenes.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/light-scenes.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy light-scenes.tsx and light-scenes.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { LightScenes } from '@/components/sundae/light-scenes'
<LightScenes />Props
| Prop | Type | Default |
|---|---|---|
lamps*Lamps (0–100). | { id: string; name: string; level: number }[] | — |
scenes*Scene levels per lamp. | Record<string, number[]> | — |
onChangeCalled on change. | (levels: number[]) => void | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- Each lamp has a labelled switch and range input.
- Scenes are a radiogroup; Custom is announced when levels diverge.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-lsc; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is Light Scenes free?
- Yes. Light Scenes is free for personal and commercial projects, with full source on this page.
- How do I install Light Scenes?
- Run npx shadcn@latest add https://sundaeui.dev/r/light-scenes.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Light Scenes support keyboard and screen readers?
- Each lamp has a labelled switch and range input. Scenes are a radiogroup; Custom is announced when levels diverge.
- Does Light Scenes respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All components →Thermostat
Components · RefinedSmart Lock
Components · RefinedToast Stack
Components · Refined- Pro
Sheet Drawer
Components · Refined Avatar Stack
Components · Refined- Pro
Notification Center
Components · Refined