Simple Light Control Component

PreviousNext

A component which allows full control of a light

Docs
ha-componentscomponent

Preview

Loading preview…
components/ha-ui/light.tsx
"use client";

import { useState, useEffect, useMemo, useRef } from "react";
import { Slider } from "@/components/ha-ui/ui/slider";
import { Button } from "@/components/ui/button";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ha-ui/ui/accordion";
import { RgbColorPicker } from "react-colorful";
import { PowerIcon, SettingsIcon, ThermometerIcon, PaintbrushVerticalIcon, XIcon } from "lucide-react";
import { Popover, PopoverTrigger, PopoverContent, PopoverClose } from "@/components/ha-ui/ui/popover";

import type { EntityId } from "@/types/entity-types";
import { haWebSocket } from "@/lib/haWebsocket";

export type LightVariants = "Default" | "Accordion" | "Popup" | "SeperatePopups";

export interface LightProps {
    /**
     * HomeAssistant Entity Name
     */
    entity: EntityId;
    /**
     * Shows the RGB Colour Picker, overriding Home Assistant's supportedfeatures
     */
    overrideSupportColorRGB?: boolean;
    /**
     * Variant of controls UI
     */
    variant?: LightVariants;
}

// Common props passed to all variant UIs
interface LightVariantProps {
    state: string;
    activeMode: string | null;
    setActiveMode: (mode: string) => void;
    supportsColor: boolean;
    supportsColorTemp: boolean;
    rgbColor: [number, number, number] | null;
    setRgbColor: (rgb: [number, number, number]) => void;
    colorTempK: number | null;
    setColorTempK: (val: number) => void;
    minTemp: number;
    maxTemp: number;
    throttledColorTemp: (val: number) => void;
    throttledRgb: (rgb: [number, number, number]) => void;
    overrideSupportColorRGB: boolean;
    entity: EntityId;
}

// ---------- Utility Functions ----------
function throttle<F extends (...args: any[]) => void>(fn: F, limit: number) {
    let inThrottle = false;
    let lastArgs: Parameters<F> | null = null;
    return (...args: Parameters<F>) => {
        if (!inThrottle) {
            fn(...args);
            inThrottle = true;
            setTimeout(() => {
                inThrottle = false;
                if (lastArgs) {
                    fn(...lastArgs);
                    lastArgs = null;
                }
            }, limit);
        } else {
            lastArgs = args;
        }
    };
}

// Approximate conversion Kelvin → RGB
// Valid for ~1000K - 40000K
function kelvinToRGB(kelvin: number): [number, number, number] {
    const temperature = kelvin / 100;
    let red, green, blue;

    if (temperature <= 66) {
        red = 255;
        green = 99.4708025861 * Math.log(temperature) - 161.1195681661;
        blue = temperature <= 19 ? 0 : 138.5177312231 * Math.log(temperature - 10) - 305.0447927307;
    } else {
        red = 329.698727446 * Math.pow(temperature - 60, -0.1332047592);
        green = 288.1221695283 * Math.pow(temperature - 60, -0.0755148492);
        blue = 255;
    }

    return [
        Math.min(255, Math.max(0, Math.round(red))),
        Math.min(255, Math.max(0, Math.round(green))),
        Math.min(255, Math.max(0, Math.round(blue))),
    ];
}

// ---------- Variant Components ----------
function DefaultVariant(props: LightVariantProps) {
    const minRGB = kelvinToRGB(props.minTemp);
    const maxRGB = kelvinToRGB(props.maxTemp);

    return (
        <div
            className="flex space-x-4"
            style={
                {
                    "--min-rgb": `rgb(${minRGB.join(",")})`,
                    "--max-rgb": `rgb(${maxRGB.join(",")})`,
                } as React.CSSProperties
            }
        >
            {props.supportsColorTemp && (
                <Slider
                    value={[props.colorTempK ?? props.minTemp]}
                    min={props.minTemp}
                    max={props.maxTemp}
                    step={50}
                    size={30}
                    orientation="vertical"
                    rangeClassName="bg-gradient-to-t from-[var(--min-rgb)] to-[var(--max-rgb)]"
                    onValueChange={(val) => {
                        props.setColorTempK(val[0]);
                        props.throttledColorTemp(val[0]);
                        props.setActiveMode("color_temp");
                    }}
                    className={props.activeMode !== "color_temp" ? "pointer-events-none opacity-40" : ""}
                />
            )}
            <div className="flex flex-col justify-end space-y-4">
                {props.overrideSupportColorRGB && !props.rgbColor && (
                    <div className="h-[calc(100%-3.25rem)]">
                        <RgbColorPicker style={{ height: "100%" }} className="pointer-events-none opacity-40" />
                    </div>
                )}
                {props.supportsColor && props.rgbColor && (
                    <div className="h-[calc(100%-3.25rem)]">
                        <RgbColorPicker
                            color={{
                                r: props.rgbColor[0],
                                g: props.rgbColor[1],
                                b: props.rgbColor[2],
                            }}
                            onChange={(c) => {
                                const rgb: [number, number, number] = [c.r, c.g, c.b];
                                props.setRgbColor(rgb);
                                props.throttledRgb(rgb);
                                props.setActiveMode("rgb");
                            }}
                            style={{ height: "100%" }}
                            className={props.activeMode !== "rgb" ? "pointer-events-none opacity-40" : ""}
                        />
                    </div>
                )}
                <div className="flex w-full justify-between">
                    <Button
                        size="icon"
                        onClick={() => {
                            haWebSocket.callService("light", "toggle", { entity_id: props.entity });
                            if (props.state === "off") {
                                if (props.supportsColorTemp) props.setActiveMode("color_temp");
                                else if (props.supportsColor) props.setActiveMode("rgb");
                            }
                        }}
                    >
                        <PowerIcon />
                    </Button>
                    {props.supportsColor &&
                        (props.rgbColor || props.overrideSupportColorRGB) &&
                        props.supportsColorTemp && (
                            <Button
                                onClick={() =>
                                    props.setActiveMode(props.activeMode === "color_temp" ? "rgb" : "color_temp")
                                }
                                className={
                                    props.overrideSupportColorRGB && !props.rgbColor
                                        ? "pointer-events-none opacity-40"
                                        : ""
                                }
                            >
                                Switch to {props.activeMode === "color_temp" ? "RGB" : "Temp"}
                            </Button>
                        )}
                </div>
            </div>
        </div>
    );
}

function HiddenAccordionVariant(props: LightVariantProps) {
    const minRGB = kelvinToRGB(props.minTemp);
    const maxRGB = kelvinToRGB(props.maxTemp);

    return (
        <Accordion type="single" collapsible orientation="horizontal" className="h-100%">
            <AccordionItem value="item-1" orientation="horizontal" className="h-full">
                <div className="flex h-full flex-col justify-between">
                    <AccordionTrigger orientation="horizontal"></AccordionTrigger>
                    <Button
                        size="icon"
                        onClick={() => {
                            haWebSocket.callService("light", "toggle", { entity_id: props.entity });
                            if (props.state === "off") {
                                // preselect a mode when turning on
                                if (props.supportsColorTemp) {
                                    props.setActiveMode("color_temp");
                                } else if (props.supportsColor) {
                                    props.setActiveMode("rgb");
                                }
                            }
                        }}
                    >
                        <PowerIcon />
                    </Button>
                </div>

                <AccordionContent orientation="horizontal" className="py-0">
                    <div
                        className="flex space-x-4"
                        style={
                            {
                                "--min-rgb": `rgb(${minRGB.join(",")})`,
                                "--max-rgb": `rgb(${maxRGB.join(",")})`,
                            } as React.CSSProperties
                        }
                    >
                        {props.supportsColorTemp && (
                            <Slider
                                value={[props.colorTempK ?? props.minTemp]}
                                min={props.minTemp}
                                max={props.maxTemp}
                                step={50}
                                size={30}
                                orientation="vertical"
                                rangeClassName="bg-gradient-to-t from-[var(--min-rgb)] to-[var(--max-rgb)]"
                                onValueChange={(val) => {
                                    props.setColorTempK(val[0]);
                                    props.throttledColorTemp(val[0]);
                                    props.setActiveMode("color_temp"); // force mode switch UI side
                                }}
                                className={props.activeMode !== "color_temp" ? "pointer-events-none opacity-40" : ""}
                            />
                        )}
                        <div className="space-y-4">
                            {props.overrideSupportColorRGB && !props.rgbColor && (
                                <div className="h-[calc(100%-3.25rem)]">
                                    <RgbColorPicker
                                        style={{ height: "100%" }}
                                        className="pointer-events-none opacity-40"
                                    />
                                </div>
                            )}
                            {props.supportsColor && props.rgbColor && (
                                <div className="h-[calc(100%-3.25rem)]">
                                    <RgbColorPicker
                                        color={{
                                            r: props.rgbColor[0],
                                            g: props.rgbColor[1],
                                            b: props.rgbColor[2],
                                        }}
                                        onChange={(c) => {
                                            const rgb: [number, number, number] = [c.r, c.g, c.b];
                                            props.setRgbColor(rgb);
                                            props.throttledRgb(rgb);
                                            props.setActiveMode("rgb"); // force mode switch UI side
                                        }}
                                        style={{ height: "100%" }}
                                        className={props.activeMode !== "rgb" ? "pointer-events-none opacity-40" : ""}
                                    />
                                </div>
                            )}
                            <div className="flex w-full justify-end">
                                {props.supportsColor &&
                                    (props.rgbColor || props.overrideSupportColorRGB) &&
                                    props.supportsColorTemp && (
                                        <Button
                                            onClick={() =>
                                                props.setActiveMode(
                                                    props.activeMode === "color_temp" ? "rgb" : "color_temp",
                                                )
                                            }
                                            className={
                                                props.overrideSupportColorRGB && !props.rgbColor
                                                    ? "pointer-events-none w-full opacity-40"
                                                    : "w-full"
                                            }
                                        >
                                            Switch to {props.activeMode == "color_temp" ? "RGB" : "Temperature"}
                                        </Button>
                                    )}
                            </div>
                        </div>
                    </div>
                </AccordionContent>
            </AccordionItem>
        </Accordion>
    );
}

function PopupVariant(props: LightVariantProps) {
    const [open, setOpen] = useState(false);
    const minRGB = kelvinToRGB(props.minTemp);
    const maxRGB = kelvinToRGB(props.maxTemp);

    return (
        <div className="h-100% flex flex-col justify-between">
            <Popover open={open} onOpenChange={setOpen}>
                <PopoverTrigger asChild>
                    <Button size="icon" variant="secondary">
                        <SettingsIcon className="size-6" />
                    </Button>
                </PopoverTrigger>

                <PopoverContent
                    className="w-fit translate-y-[-40%] pt-0 pr-0"
                    align="center"
                    side="bottom"
                    sideOffset={0}
                >
                    <div className="flex items-center justify-between py-2 pr-1">
                        <h3>Light Controls</h3>
                        <PopoverClose asChild>
                            <Button size="icon" variant="ghost" className="group">
                                <XIcon className="size-4 transition-transform duration-200 group-hover:scale-125" />
                            </Button>
                        </PopoverClose>
                    </div>
                    <div
                        className="safe-to-interact flex space-x-4 pr-4"
                        style={
                            {
                                "--min-rgb": `rgb(${minRGB.join(",")})`,
                                "--max-rgb": `rgb(${maxRGB.join(",")})`,
                            } as React.CSSProperties
                        }
                    >
                        {props.supportsColorTemp && (
                            <div className="safe-to-interact">
                                <Slider
                                    value={[props.colorTempK ?? props.minTemp]}
                                    min={props.minTemp}
                                    max={props.maxTemp}
                                    step={50}
                                    size={30}
                                    orientation="vertical"
                                    rangeClassName="bg-gradient-to-t from-[var(--min-rgb)] to-[var(--max-rgb)]"
                                    onValueChange={(val) => {
                                        props.setColorTempK(val[0]);
                                        props.throttledColorTemp(val[0]);
                                        props.setActiveMode("color_temp");
                                    }}
                                    className={
                                        props.activeMode !== "color_temp" ? "pointer-events-none opacity-40" : ""
                                    }
                                />
                            </div>
                        )}

                        <div className="safe-to-interact space-y-4">
                            {props.overrideSupportColorRGB && !props.rgbColor && (
                                <div className="h-[calc(100%-3.25rem)]">
                                    <RgbColorPicker
                                        style={{ height: "100%" }}
                                        className="pointer-events-none opacity-40"
                                    />
                                </div>
                            )}
                            {props.supportsColor && props.rgbColor && (
                                <div className="h-[calc(100%-3.25rem)]">
                                    <RgbColorPicker
                                        color={{
                                            r: props.rgbColor[0],
                                            g: props.rgbColor[1],
                                            b: props.rgbColor[2],
                                        }}
                                        onChange={(c) => {
                                            const rgb: [number, number, number] = [c.r, c.g, c.b];
                                            props.setRgbColor(rgb);
                                            props.throttledRgb(rgb);
                                            props.setActiveMode("rgb");
                                        }}
                                        style={{ height: "100%" }}
                                        className={props.activeMode !== "rgb" ? "pointer-events-none opacity-40" : ""}
                                    />
                                </div>
                            )}
                            <div className="flex w-full justify-end">
                                {props.supportsColor &&
                                    (props.rgbColor || props.overrideSupportColorRGB) &&
                                    props.supportsColorTemp && (
                                        <Button
                                            onClick={() =>
                                                props.setActiveMode(
                                                    props.activeMode === "color_temp" ? "rgb" : "color_temp",
                                                )
                                            }
                                            className={
                                                props.overrideSupportColorRGB && !props.rgbColor
                                                    ? "pointer-events-none opacity-40"
                                                    : ""
                                            }
                                        >
                                            Switch to {props.activeMode == "color_temp" ? "RGB" : "Temperature"}
                                        </Button>
                                    )}
                            </div>
                        </div>
                    </div>
                </PopoverContent>
            </Popover>
            <Button
                size="icon"
                onClick={() => {
                    haWebSocket.callService("light", "toggle", { entity_id: props.entity });
                    if (props.state === "off") {
                        // preselect a mode when turning on
                        if (props.supportsColorTemp) {
                            props.setActiveMode("color_temp");
                        } else if (props.supportsColor) {
                            props.setActiveMode("rgb");
                        }
                    }
                }}
            >
                <PowerIcon />
            </Button>
        </div>
    );
}

function SeperatePopupVariant(props: LightVariantProps) {
    const [openColourTemp, setOpenColourTemp] = useState(false);
    const [openColourRGB, setOpenColourRGB] = useState(false);
    const minRGB = kelvinToRGB(props.minTemp);
    const maxRGB = kelvinToRGB(props.maxTemp);

    return (
        <div className="h-100% flex flex-col justify-between">
            {props.supportsColorTemp && (
                <Popover
                    open={openColourTemp}
                    onOpenChange={(open) => {
                        setOpenColourTemp(open);
                        props.setActiveMode("color_temp");
                    }}
                >
                    <PopoverTrigger asChild>
                        <Button size="icon" variant="secondary">
                            <ThermometerIcon className="size-6" />
                        </Button>
                    </PopoverTrigger>

                    <PopoverContent
                        className="w-fit translate-y-[-40%] px-1 pt-1"
                        align="center"
                        side="bottom"
                        sideOffset={0}
                    >
                        <div className="flex w-full justify-end">
                            <PopoverClose asChild>
                                <Button size="icon" variant="ghost" className="group">
                                    <XIcon className="size-4 transition-transform duration-200 group-hover:scale-125" />
                                </Button>
                            </PopoverClose>
                        </div>

                        <div
                            className="safe-to-interact px-13 pb-3"
                            style={
                                {
                                    "--min-rgb": `rgb(${minRGB.join(",")})`,
                                    "--max-rgb": `rgb(${maxRGB.join(",")})`,
                                } as React.CSSProperties
                            }
                        >
                            <Slider
                                value={[props.colorTempK ?? props.minTemp]}
                                min={props.minTemp}
                                max={props.maxTemp}
                                step={50}
                                size={30}
                                orientation="vertical"
                                rangeClassName="bg-gradient-to-t from-[var(--min-rgb)] to-[var(--max-rgb)]"
                                onValueChange={(val) => {
                                    props.setColorTempK(val[0]);
                                    props.throttledColorTemp(val[0]);
                                }}
                                className={props.activeMode !== "color_temp" ? "pointer-events-none opacity-40" : ""}
                            />
                        </div>
                    </PopoverContent>
                </Popover>
            )}

            {props.supportsColor && (props.rgbColor || props.overrideSupportColorRGB) && (
                <Popover
                    open={openColourRGB}
                    onOpenChange={(open) => {
                        setOpenColourRGB(open);
                        props.setActiveMode("rgb");
                    }}
                >
                    <PopoverTrigger asChild>
                        <Button size="icon" variant="secondary">
                            <PaintbrushVerticalIcon className="size-6" />
                        </Button>
                    </PopoverTrigger>

                    <PopoverContent
                        className="w-fit translate-y-[-50%] px-1 pt-1"
                        align="center"
                        side="bottom"
                        sideOffset={0}
                    >
                        <div className="flex w-full justify-end">
                            <PopoverClose asChild>
                                <Button size="icon" variant="ghost" className="group">
                                    <XIcon className="size-4 transition-transform duration-200 group-hover:scale-125" />
                                </Button>
                            </PopoverClose>
                        </div>
                        {props.overrideSupportColorRGB && !props.rgbColor && (
                            <div className="safe-to-interact">
                                <RgbColorPicker style={{ height: "100%" }} className="pointer-events-none opacity-40" />
                            </div>
                        )}
                        {props.supportsColor && props.rgbColor && (
                            <div className="h-48 px-7 pb-4">
                                <RgbColorPicker
                                    color={{
                                        r: props.rgbColor[0],
                                        g: props.rgbColor[1],
                                        b: props.rgbColor[2],
                                    }}
                                    onChange={(c) => {
                                        const rgb: [number, number, number] = [c.r, c.g, c.b];
                                        props.setRgbColor(rgb);
                                        props.throttledRgb(rgb);
                                    }}
                                    style={{ height: "100%" }}
                                    className={props.activeMode !== "rgb" ? "pointer-events-none opacity-40" : ""}
                                />
                            </div>
                        )}
                    </PopoverContent>
                </Popover>
            )}
            <Button
                size="icon"
                onClick={() => {
                    haWebSocket.callService("light", "toggle", { entity_id: props.entity });
                    if (props.state === "off") {
                        // preselect a mode when turning on
                        if (props.supportsColorTemp) {
                            props.setActiveMode("color_temp");
                        } else if (props.supportsColor) {
                            props.setActiveMode("rgb");
                        }
                    }
                }}
            >
                <PowerIcon />
            </Button>
        </div>
    );
}

export function Light({ entity, overrideSupportColorRGB = false, variant = "Default" }: LightProps) {
    const [brightness, setBrightness] = useState(128);
    const [colorTempK, setColorTempK] = useState<number | null>(null);
    const [rgbColor, setRgbColor] = useState<[number, number, number] | null>(null);

    const [state, setState] = useState("off");
    const [activeMode, setActiveMode] = useState<string | null>(null);

    const [supportsColor, setSupportsColor] = useState(false);
    const [supportsColorTemp, setSupportsColorTemp] = useState(false);
    const [minTemp, setMinTemp] = useState(2000);
    const [maxTemp, setMaxTemp] = useState(6500);

    const pendingBrightness = useRef<number | null>(null);
    const pendingTemp = useRef<number | null>(null);
    const pendingRgb = useRef<[number, number, number] | null>(null);

    // Throttled API Calls

    const throttledSetBrightness = useMemo(
        () =>
            throttle((value: number) => {
                pendingBrightness.current = value;
                haWebSocket.callService("light", "turn_on", {
                    entity_id: entity,
                    brightness: value,
                });
            }, 150), // Throttle: 150ms per update
        [entity],
    );

    const throttledColorTemp = useMemo(
        () =>
            throttle((kelvin: number) => {
                pendingTemp.current = kelvin;
                haWebSocket.callService("light", "turn_on", {
                    entity_id: entity,
                    color_temp_kelvin: kelvin,
                });
            }, 200),
        [entity],
    );

    const throttledRgb = useMemo(
        () =>
            throttle((rgb: [number, number, number]) => {
                pendingRgb.current = rgb;
                haWebSocket.callService("light", "turn_on", {
                    entity_id: entity,
                    rgb_color: rgb,
                });
            }, 200),
        [entity],
    );

    useEffect(() => {
        // Load Initial State
        haWebSocket.getState(entity).then((data) => {
            if (data) {
                const { attributes } = data;
                setState(data.state);
                if (attributes?.brightness !== undefined && attributes?.brightness !== null) {
                    setBrightness(attributes.brightness);
                }
                if (attributes?.color_temp_kelvin && attributes?.color_temp_kelvin !== null) {
                    setColorTempK(attributes.color_temp_kelvin);
                }
                if (
                    attributes?.rgb_color !== undefined &&
                    attributes?.rgb_color !== null &&
                    activeMode !== "color_temp"
                ) {
                    setRgbColor(attributes.rgb_color);
                }
                if (attributes?.color_mode) {
                    setActiveMode(attributes.color_mode);
                }

                // Detect supported Color Features
                const modes: string[] = attributes?.supported_color_modes || [];
                if (modes.includes("color_temp")) {
                    setSupportsColorTemp(true);
                    setMinTemp(attributes.min_color_temp_kelvin || 2000);
                    setMaxTemp(attributes.max_color_temp_kelvin || 6500);
                }
                if (
                    modes.includes("hs") ||
                    modes.includes("rgb") ||
                    modes.includes("rgbw") ||
                    modes.includes("rgbww") ||
                    modes.includes("xy")
                ) {
                    setSupportsColor(true);
                }
            }
        });
        // Subscribe to state updates
        const handler = (newState: any) => {
            setState(newState.state);
            const { attributes } = newState;

            // Brightness confirm with tolerance
            if (attributes?.brightness !== undefined && attributes?.brightness !== null) {
                if (pendingBrightness.current !== null) {
                    if (Math.abs(attributes.brightness - pendingBrightness.current) <= 2) {
                        setBrightness(attributes.brightness);
                        pendingBrightness.current = null;
                    } else {
                        return; // rollback -> ignore
                    }
                } else {
                    setBrightness(attributes.brightness);
                }
            }

            // Color Temp confirm with tolerance
            if (attributes?.color_temp_kelvin !== undefined && attributes?.color_temp_kelvin !== null) {
                if (pendingTemp.current !== null) {
                    if (Math.abs(attributes.color_temp_kelvin - pendingTemp.current) <= 50) {
                        setColorTempK(attributes.color_temp_kelvin);
                        pendingTemp.current = null;
                    } else {
                        return;
                    }
                } else {
                    setColorTempK(attributes.color_temp_kelvin);
                }
            }

            // RGB confirm with tolerance
            if (
                attributes?.rgb_color !== undefined &&
                attributes?.rgb_color !== null &&
                attributes.rgb_color.length === 3
            ) {
                const [r, g, b] = attributes.rgb_color;
                if (pendingRgb.current !== null) {
                    const [pr, pg, pb] = pendingRgb.current;
                    if (Math.abs(r - pr) <= 2 && Math.abs(g - pg) <= 2 && Math.abs(b - pb) <= 2) {
                        setRgbColor([r, g, b]);
                        pendingRgb.current = null;
                    } else {
                        return;
                    }
                } else {
                    setRgbColor([r, g, b]);
                }
            }
        };
        haWebSocket.addStateListener(entity, handler);

        return () => haWebSocket.removeStateListener(entity, handler);
    }, [entity]);

    const VariantMap: Record<LightVariants, React.FC<LightVariantProps>> = {
        Default: DefaultVariant,
        Accordion: HiddenAccordionVariant,
        Popup: PopupVariant,
        SeperatePopups: SeperatePopupVariant,
    };
    const VariantUI = VariantMap[variant];

    return (
        <div className="flex space-x-4">
            <Slider
                value={[brightness]}
                max={255}
                step={5}
                size={30}
                className="w-[400px]"
                orientation="vertical"
                onValueChange={(value) => {
                    setBrightness(value[0]); // Update UI in real-time
                    throttledSetBrightness(value[0]); // Throttle updates to prevent desync issues
                }}
            />
            <VariantUI
                entity={entity}
                overrideSupportColorRGB={overrideSupportColorRGB}
                supportsColor={supportsColor}
                supportsColorTemp={supportsColorTemp}
                minTemp={minTemp}
                maxTemp={maxTemp}
                colorTempK={colorTempK}
                setColorTempK={setColorTempK}
                rgbColor={rgbColor}
                setRgbColor={setRgbColor}
                activeMode={activeMode}
                setActiveMode={setActiveMode}
                throttledColorTemp={throttledColorTemp}
                throttledRgb={throttledRgb}
                state={state}
            />
        </div>
    );
}

Installation

npx shadcn@latest add @ha-components/light

Usage

import { Light } from "@/components/light"
<Light />