shadimage
shivraj-roy
itembloom-color-pickersource: ok · refreshed

Bloom Color Picker

A flower-inspired color picker — a swatch that blooms open into petals. Zero dependencies.

npx shadcn add "https://www.shivrajroy.in/r/bloom-color-picker.json"
free

No selection tags yet — empty means no evidence, not lack of support.

Files6 · source cached 10/8/2026, 3:33:22 AM

packages/react/src/index.tsx
"use client";

import * as React from "react";

import "./style.css";

import { deriveFromHex, normalizeHex, shadeOf } from "./color";
import { bloomPalettes } from "./palettes";
import type { BloomColorPickerPart, BloomColorPickerProps } from "./types";
import { useControllableState } from "./use-controllable-state";

const TAU = Math.PI * 2;

// Base geometry, defined at size = 50 (closed swatch diameter). Everything scales linearly.
const BASE_SWATCH = 50;
const BLOOM_SIZE = 280;
const PETAL_SIZE = 54;
const OUTER_RADIUS = 78;
const INNER_RADIUS = 42;

// Brightness arc (SVG canvas units — the svg element scales as a whole)
const ARC_CANVAS = 360;
const ARC_C = ARC_CANVAS / 2;
const ARC_RADIUS = 170;
const ARC_HALF_SPAN = 26; // degrees above & below 3 o'clock
const ARC_STROKE = 20;

interface Petal {
   key: string;
   x: number;
   y: number;
   color: string;
   order: number;
}

function buildPetals(outer: string[], inner: string[]): Petal[] {
   const raw: Array<Omit<Petal, "order"> & { radius: number; angleNorm: number }> = [];

   // Outer ring first so inner petals overlap on top.
   outer.forEach((color, i) => {
      const angle = (i / outer.length) * TAU - Math.PI / 2;
      raw.push({
         key: `o${i}`,
         x: Math.cos(angle) * OUTER_RADIUS,
         y: Math.sin(angle) * OUTER_RADIUS,
         color,
         radius: OUTER_RADIUS,
         angleNorm: i / outer.length,
      });
   });

   inner.forEach((color, i) => {
      const angle = (i / inner.length) * TAU - Math.PI / 2;
      raw.push({
         key: `i${i}`,
         x: Math.cos(angle) * INNER_RADIUS,
         y: Math.sin(angle) * INNER_RADIUS,
         color,
         radius: INNER_RADIUS,
         angleNorm: i / inner.length,
      });
   });

   // White center on top
   raw.push({ key: "center", x: 0, y: 0, color: "#FFFFFF", radius: 0, angleNorm: 0 });

   // Spiral reveal order: radius + angle so it winds outward (rings interleave)
   const orderOf = new Map<string, number>();
   raw.map((p) => ({ key: p.key, m: p.radius / OUTER_RADIUS + p.angleNorm }))
      .sort((a, b) => a.m - b.m)
      .forEach((e, idx) => orderOf.set(e.key, idx));

   return raw.map((p) => ({
      key: p.key,
      x: p.x,
      y: p.y,
      color: p.color,
      order: orderOf.get(p.key)!,
   }));
}

function arcPath(): string {
   const a0 = (-ARC_HALF_SPAN * Math.PI) / 180;
   const a1 = (ARC_HALF_SPAN * Math.PI) / 180;
   const x0 = ARC_C + ARC_RADIUS * Math.cos(a0);
   const y0 = ARC_C + ARC_RADIUS * Math.sin(a0);
   const x1 = ARC_C + ARC_RADIUS * Math.cos(a1);
   const y1 = ARC_C + ARC_RADIUS * Math.sin(a1);
   return `M${x0} ${y0} A ${ARC_RADIUS} ${ARC_RADIUS} 0 0 1 ${x1} ${y1}`;
}

const ARC_PATH = arcPath();

function cx(...parts: Array<string | false | null | undefined>): string {
   return parts.filter(Boolean).join(" ");
}

const FALLBACK_HEX = "#F5B81E";

export function BloomColorPicker(props: BloomColorPickerProps) {
   const {
      value: valueProp,
      defaultValue = FALLBACK_HEX,
      onChange,
      open: openProp,
      defaultOpen = false,
      onOpenChange,
      palette = "warm",
      outerColors,
      innerColors,
      size = 28,
      disabled = false,
      hexInput = true,
      inputVariant = "split",
      motion = "subtle",
      theme = "light",
      className,
      classNames,
      "aria-label": ariaLabel = "Pick a color",
   } = props;

   const paletteColors = bloomPalettes[palette] ?? bloomPalettes.warm;
   const resolvedOuterColors = outerColors ?? paletteColors.outer;
   const resolvedInnerColors = innerColors ?? paletteColors.inner;

   const scale = size / BASE_SWATCH;
   const part = (name: BloomColorPickerPart) => classNames?.[name];

   // "none" swaps the full spring choreography for one quick uniform fade —
   // these match the 160ms duration forced via [data-motion="none"] in style.css.
   const instant = motion === "none";
   const FADE_MS = 160;
   const PRESS_MS = instant ? FADE_MS : 140;
   const BLOOM_DELAY_MS = instant ? FADE_MS : 1300;
   const CLOSE_PETALS_MS = instant ? 0 : 110;
   const CLOSE_UNMOUNT_MS = instant ? FADE_MS : 480;

   // Outline rings scale with the picker so proportions match the original at any size
   const ring = (color: string): React.CSSProperties => ({
      outline: `${2 * scale}px solid ${color}`,
      outlineOffset: `${-2 * scale}px`,
   });

   const [rawValue, setValue] = useControllableState(valueProp, defaultValue, onChange);
   const hex = normalizeHex(rawValue) ?? FALLBACK_HEX;
   const { base, lightPos } = React.useMemo(() => deriveFromHex(hex), [hex]);

   // Draft text for the optional hex input: lets the field hold invalid/partial
   // text while typing, but stays in sync when the color changes elsewhere
   // (petal pick, arc drag) without fighting an in-progress keystroke.
   const [hexDraft, setHexDraft] = React.useState(hex);
   React.useEffect(() => {
      if (normalizeHex(hexDraft) !== hex) setHexDraft(hex);
   }, [hex]);

   const [open, setOpen] = useControllableState(openProp, defaultOpen, onOpenChange);

   // Presentation lifecycle: the bloom stays mounted through the two-phase close
   // (circles collapse at 0ms, petals converge at 110ms, unmount at 480ms).
   const [rendered, setRendered] = React.useState(open);
   const [closing, setClosing] = React.useState(false);
   const [petalsHome, setPetalsHome] = React.useState(false);
   const [bloomed, setBloomed] = React.useState(false); // spiral entrance finished
   const [pressing, setPressing] = React.useState(false);
   const [dragging, setDragging] = React.useState(false);
   const [hovered, setHovered] = React.useState<string | null>(null);

   const renderedRef = React.useRef(rendered);
   renderedRef.current = rendered;

   const containerRef = React.useRef<HTMLDivElement>(null);
   const dishRef = React.useRef<HTMLDivElement>(null);
   const svgRef = React.useRef<SVGSVGElement>(null);

   const gradientId = React.useId();

   const petals = React.useMemo(
      () => buildPetals(resolvedOuterColors, resolvedInnerColors),
      [resolvedOuterColors, resolvedInnerColors]
   );

   React.useEffect(() => {
      const timers: number[] = [];
      if (open) {
         setRendered(true);
         setClosing(false);
         setPetalsHome(false);
         timers.push(window.setTimeout(() => setBloomed(true), BLOOM_DELAY_MS));
      } else {
         setBloomed(false);
         setHovered(null);
         if (renderedRef.current) {
            setClosing(true);
            timers.push(window.setTimeout(() => setPetalsHome(true), CLOSE_PETALS_MS));
            timers.push(
               window.setTimeout(() => {
                  setRendered(false);
                  setClosing(false);
                  setPetalsHome(false);
               }, CLOSE_UNMOUNT_MS)
            );
         }
      }
      return () => timers.forEach(clearTimeout);
   }, [open, BLOOM_DELAY_MS, CLOSE_PETALS_MS, CLOSE_UNMOUNT_MS]);

   // Close on outside click / Escape
   React.useEffect(() => {
      if (!open) return;
      const onDown = (e: PointerEvent) => {
         if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
            setOpen(false);
         }
      };
      const onKey = (e: KeyboardEvent) => {
         if (e.key === "Escape") setOpen(false);
      };
      document.addEventListener("pointerdown", onDown);
      document.addEventListener("keydown", onKey);
      return () => {
         document.removeEventListener("pointerdown", onDown);
         document.removeEventListener("keydown", onKey);
      };
   }, [open, setOpen]);

   const openPicker = () => {
      if (disabled) return;
      setPressing(true);
      window.setTimeout(() => {
         setPressing(false);
         setOpen(true);
      }, PRESS_MS);
   };

   // Single source of truth for hover: pick the nearest petal under the pointer.
   // Avoids missed enter/leave events between overlapping petals.
   const handleDishMove = (e: React.PointerEvent) => {
      if (!bloomed || !dishRef.current) return;
      const rect = dishRef.current.getBoundingClientRect();
      const px = e.clientX - rect.left - rect.width / 2;
      const py = e.clientY - rect.top - rect.height / 2;
      let best: string | null = null;
      let bestDist = ((PETAL_SIZE * scale) / 2) ** 2;
      for (const p of petals) {
         const d = (px - p.x * scale) ** 2 + (py - p.y * scale) ** 2;
         if (d <= bestDist) {
            bestDist = d;
            best = p.key;
         }
      }
      setHovered(best);
   };

   const shade = hex;
   const ringColor = `color-mix(in srgb, color-mix(in srgb, ${shade}, #000 30%) 14%, transparent)`;

   // Knob position along the arc
   const knobAngle = ((-ARC_HALF_SPAN + lightPos * 2 * ARC_HALF_SPAN) * Math.PI) / 180;
   const knobX = ARC_C + ARC_RADIUS * Math.cos(knobAngle);
   const knobY = ARC_C + ARC_RADIUS * Math.sin(knobAngle);

   const onKnobDown = (e: React.PointerEvent) => {
      e.preventDefault();
      setDragging(true);
      const dragBase = base; // hue/saturation are stable for the whole drag
      const updateFromPointer = (clientX: number, clientY: number) => {
         const svg = svgRef.current;
         if (!svg) return;
         const rect = svg.getBoundingClientRect();
         const lx = (clientX - rect.left) * (ARC_CANVAS / rect.width);
         const ly = (clientY - rect.top) * (ARC_CANVAS / rect.height);
         let deg = (Math.atan2(ly - ARC_C, lx - ARC_C) * 180) / Math.PI;
         deg = Math.max(-ARC_HALF_SPAN, Math.min(ARC_HALF_SPAN, deg));
         const pos = (deg + ARC_HALF_SPAN) / (2 * ARC_HALF_SPAN);
         setValue(shadeOf(dragBase, pos));
      };
      updateFromPointer(e.clientX, e.clientY);
      const move = (ev: PointerEvent) => updateFromPointer(ev.clientX, ev.clientY);
      const up = () => {
         setDragging(false);
         window.removeEventListener("pointermove", move);
         window.removeEventListener("pointerup", up);
      };
      window.addEventListener("pointermove", move);
      window.addEventListener("pointerup", up);
   };

   const pickPetal = (color: string) => {
      const normalized = normalizeHex(color);
      if (!normalized) return;
      // Round-trip through the internal model so the reported hex matches
      // what the knob/gradient will display for this petal.
      const { base: petalBase, lightPos: petalPos } = deriveFromHex(normalized);
      setValue(shadeOf(petalBase, petalPos));
   };

   return (
      <div
         ref={containerRef}
         className={cx("bcp", dragging && "bcp--dragging", className, part("root"))}
         style={{ "--bcp-scale": scale } as React.CSSProperties}
         data-slot="bcp-root"
         data-state={open ? "open" : "closed"}
         data-disabled={disabled || undefined}
         data-motion={motion}
         data-input-variant={hexInput ? inputVariant : undefined}
         data-theme={theme !== "auto" ? theme : undefined}
         onPointerMove={handleDishMove}
         onPointerLeave={() => setHovered(null)}
      >
         {rendered ? (
            <div className="bcp__slot">
               <svg
                  ref={svgRef}
                  className={cx("bcp__arc", closing && "bcp__arc--closing", part("arc"))}
                  data-slot="bcp-arc"
                  width={ARC_CANVAS * scale}
                  height={ARC_CANVAS * scale}
                  viewBox={`0 0 ${ARC_CANVAS} ${ARC_CANVAS}`}
                  fill="none"
                  xmlns="http://www.w3.org/2000/svg"
               >
                  <defs>
                     <linearGradient
                        id={gradientId}
                        gradientUnits="userSpaceOnUse"
                        x1={ARC_C}
                        y1={ARC_C - ARC_RADIUS * Math.sin((ARC_HALF_SPAN * Math.PI) / 180)}
                        x2={ARC_C}
                        y2={ARC_C + ARC_RADIUS * Math.sin((ARC_HALF_SPAN * Math.PI) / 180)}
                     >
                        <stop offset="0" stopColor="#ffffff" />
                        <stop offset="0.35" stopColor={base} />
                        <stop offset="0.65" stopColor={base} />
                        <stop offset="1" stopColor="#000000" />
                     </linearGradient>
                  </defs>
                  <path
                     d={ARC_PATH}
                     stroke={ringColor}
                     strokeWidth={ARC_STROKE + 4}
                     strokeLinecap="round"
                  />
                  <path
                     d={ARC_PATH}
                     stroke={`url(#${gradientId})`}
                     strokeWidth={ARC_STROKE}
                     strokeLinecap="round"
                  />

                  {/* Knob: selected shade with a white ring */}
                  <g
                     className={cx("bcp__knob", part("knob"))}
                     data-slot="bcp-knob"
                     onPointerDown={onKnobDown}
                     style={{ cursor: dragging ? "grabbing" : "grab" }}
                  >
                     <circle className="bcp__knob-halo" cx={knobX} cy={knobY} fill="#fff" />
                     <circle className="bcp__knob-core" cx={knobX} cy={knobY} fill={shade} />
                  </g>
               </svg>

               <div
                  className={cx("bcp__bloom", closing && "bcp__bloom--closing", part("bloom"))}
                  data-slot="bcp-bloom"
                  style={{ background: shade, ...ring(ringColor) }}
               >
                  <div
                     ref={dishRef}
                     className={cx("bcp__dish", part("dish"))}
                     data-slot="bcp-dish"
                     style={ring(ringColor)}
                  />
               </div>

               {/* Petals — siblings of the bloom so they outlive the circle close */}
               {petals.map((p) => (
                  <button
                     key={p.key}
                     type="button"
                     className={cx(
                        "bcp__petal",
                        hovered === p.key && !petalsHome && "bcp__petal--hovered",
                        petalsHome && "bcp__petal--home",
                        part("petal")
                     )}
                     data-slot="bcp-petal"
                     style={
                        {
                           width: PETAL_SIZE * scale,
                           height: PETAL_SIZE * scale,
                           marginLeft: (-PETAL_SIZE / 2) * scale,
                           marginTop: (-PETAL_SIZE / 2) * scale,
                           left: `calc(50% + ${p.x * scale}px)`,
                           top: `calc(50% + ${p.y * scale}px)`,
                           background: p.color,
                           ...ring(
                              `color-mix(in srgb, color-mix(in srgb, ${p.color}, #000 30%) 18%, transparent)`
                           ),
                           "--bcp-from-x": `${-p.x * scale}px`,
                           "--bcp-from-y": `${-p.y * scale}px`,
                           "--bcp-petal-delay": `${0.06 + p.order * 0.022}s`,
                        } as React.CSSProperties
                     }
                     onClick={() => pickPetal(p.color)}
                     aria-label={p.color}
                  />
               ))}
            </div>
         ) : (
            <button
               type="button"
               className={cx("bcp__swatch", pressing && "bcp__swatch--pressing", part("swatch"))}
               data-slot="bcp-swatch"
               style={{ backgroundColor: shade }}
               onClick={openPicker}
               disabled={disabled}
               aria-label={ariaLabel}
               aria-haspopup="dialog"
               aria-expanded={open}
            />
         )}

         {hexInput && (
            <div className="bcp__input-wrap">
               <input
                  type="text"
                  size={7}
                  className={cx("bcp__input", part("input"))}
                  data-slot="bcp-input"
                  value={hexDraft}
                  onChange={(e) => {
                     const raw = e.target.value.toUpperCase();
                     const hasHash = raw.startsWith("#");
                     const digits = raw.replace(/[^0-9A-F]/g, "").slice(0, 6);
                     const next = (hasHash ? "#" : "") + digits;
                     setHexDraft(next);
                     const valid = normalizeHex(next);
                     if (valid) setValue(valid);
                  }}
                  disabled={disabled}
                  spellCheck={false}
                  placeholder="#RRGGBB"
                  aria-label="Hex color value"
               />
            </div>
         )}
      </div>
   );
}

export default BloomColorPicker;

export type {
   BloomColorPickerInputVariant,
   BloomColorPickerMotion,
   BloomColorPickerPart,
   BloomColorPickerProps,
   BloomColorPickerTheme,
} from "./types";
export { deriveFromHex, hexToHsl, hslToHex, normalizeHex, shadeOf } from "./color";
export {
   bloomPalettes,
   defaultInnerColors,
   defaultOuterColors,
   type BloomColorPickerPalette,
   type BloomColorPickerPaletteColors,
} from "./palettes";
packages/react/src/types.ts
import type { BloomColorPickerPalette } from "./palettes";

export type BloomColorPickerPart =
   | "root"
   | "swatch"
   | "bloom"
   | "dish"
   | "petal"
   | "arc"
   | "knob"
   | "input";

export type BloomColorPickerMotion = "none" | "subtle" | "bouncy";

export type BloomColorPickerTheme = "auto" | "light" | "dark";

export type BloomColorPickerInputVariant = "split" | "grouped";

export interface BloomColorPickerProps {
   /** Controlled hex value, e.g. "#F5B81E". */
   value?: string;
   /** Initial hex value when uncontrolled. @default "#F5B81E" */
   defaultValue?: string;
   /** Fired with the new hex (uppercase "#RRGGBB") on every petal pick or brightness drag. */
   onChange?: (hex: string) => void;

   /** Controlled open state of the bloom. */
   open?: boolean;
   /** Initial open state when uncontrolled. @default false */
   defaultOpen?: boolean;
   /** Fired when the picker requests to open/close (swatch click, outside click, Escape). */
   onOpenChange?: (open: boolean) => void;

   /**
    * A built-in petal color scheme: "warm" (default), "ocean", "blossom", "pastel".
    * Ignored for a ring where `outerColors`/`innerColors` is explicitly passed.
    */
   palette?: BloomColorPickerPalette;
   /** Outer ring petal colors, clockwise from the top. Hex only. Overrides `palette`. */
   outerColors?: string[];
   /** Inner ring petal colors, clockwise from the top. Hex only. Overrides `palette`. */
   innerColors?: string[];

   /**
    * Diameter of the closed swatch in px; the whole bloom scales proportionally.
    * @default 28
    */
   size?: number;

   /** Disables opening the picker. @default false */
   disabled?: boolean;

   /**
    * Shows an editable hex text field next to the closed swatch. Typed
    * values are validated live; invalid characters can't be typed at all.
    * Set to `false` to hide it. @default true
    */
   hexInput?: boolean;

   /**
    * Layout for the swatch + hex input: "split" keeps them as separate
    * elements (default), "grouped" wraps both in one shared box.
    * Ignored when `hexInput` is false.
    * @default "split"
    */
   inputVariant?: BloomColorPickerInputVariant;

   /**
    * Spring intensity for the open/close and pick animations.
    * "none" disables animation entirely (instant open/close, respected
    * regardless of the visitor's OS motion setting).
    * @default "subtle"
    */
   motion?: BloomColorPickerMotion;

   /**
    * "light" or "dark" pins the picker's own chrome (input field, dish,
    * shadows — not petal colors, which come from `palette`/`outerColors`/
    * `innerColors`) to that theme. "auto" follows the visitor's system/OS
    * `prefers-color-scheme` setting instead of any explicit override.
    * @default "light"
    */
   theme?: BloomColorPickerTheme;

   /** Class applied to the root element. */
   className?: string;
   /** Per-part class overrides for restyling. */
   classNames?: Partial<Record<BloomColorPickerPart, string>>;

   /** Accessible label for the closed swatch button. @default "Pick a color" */
   "aria-label"?: string;
}
packages/react/src/palettes.ts
export interface BloomColorPickerPaletteColors {
   outer: string[];
   inner: string[];
}

// "warm" — curated warm color wheel, clockwise from the top. The original palette.
const warmOuter = [
   "#F7C13F", // yellow (top)
   "#F2A23C", // amber
   "#EE8440", // orange
   "#E96544", // coral
   "#E84C3F", // red
   "#E03E66", // rose
   "#D23C92", // magenta (bottom)
   "#A24FC8", // purple
   "#7B5FD4", // violet
   "#4E72D6", // blue
   "#3DA1B8", // teal
   "#5FB95B", // green
];
const warmInner = [
   "#F6E6A4", // pale yellow (top)
   "#F4D0B0", // pale peach
   "#F1C1C4", // pale red/pink
   "#E3C4DE", // pale magenta
   "#CCC9EC", // pale violet/blue
   "#C6E0C9", // pale teal/green
];

// "ocean" — cool blues, teals, and greens.
const oceanOuter = [
   "#3D9BE0",
   "#3DB8D6",
   "#3DCBB8",
   "#4ED68B",
   "#5FB95B",
   "#8FCB4E",
   "#C9D63D",
   "#8B9BE0",
   "#7B5FD4",
   "#5F72E0",
   "#4E86E8",
   "#3DAEE0",
];
const oceanInner = ["#BBDFF5", "#B8E8E0", "#C6E8C0", "#DCEDB5", "#C9CCF0", "#B5D2F0"];

// "blossom" — warm pinks and magentas.
const blossomOuter = [
   "#F7C13F",
   "#F5A852",
   "#F28B66",
   "#EE6E7B",
   "#E85A94",
   "#E04FB0",
   "#C94FD1",
   "#A45FE0",
   "#E0559A",
   "#F06880",
   "#F5895E",
   "#F7A94A",
];
const blossomInner = ["#F8E7B5", "#F7D6BB", "#F5C6CC", "#EFC3E3", "#E0C6F0", "#F5CBBE"];

// "pastel" — soft, low-saturation tones throughout both rings.
const pastelOuter = [
   "#F5D9A8",
   "#F3C9B0",
   "#F0BCC0",
   "#E7BCDA",
   "#D4C0EC",
   "#C3C6EE",
   "#BDD6EA",
   "#BDE2DD",
   "#C4E6C4",
   "#DCE9B8",
   "#EEE3AE",
   "#F2D6A6",
];
const pastelInner = ["#FBEFD8", "#FAE4DC", "#F8DDE2", "#EFDCF0", "#E4DFF7", "#DDE8F6"];

export const bloomPalettes = {
   warm: { outer: warmOuter, inner: warmInner },
   ocean: { outer: oceanOuter, inner: oceanInner },
   blossom: { outer: blossomOuter, inner: blossomInner },
   pastel: { outer: pastelOuter, inner: pastelInner },
} satisfies Record<string, BloomColorPickerPaletteColors>;

export type BloomColorPickerPalette = keyof typeof bloomPalettes;

// Preserved for backwards compat with earlier exports.
export const defaultOuterColors = warmOuter;
export const defaultInnerColors = warmInner;
packages/react/src/color.ts
export function hexToRgb(h: string): [number, number, number] {
   h = h.replace("#", "");
   if (h.length === 3)
      h = h
         .split("")
         .map((c) => c + c)
         .join("");
   const n = parseInt(h, 16);
   return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}

export function rgbToHex(r: number, g: number, b: number): string {
   return (
      "#" +
      [r, g, b]
         .map((v) =>
            Math.round(Math.min(255, Math.max(0, v)))
               .toString(16)
               .toUpperCase()
               .padStart(2, "0")
         )
         .join("")
   );
}

export function mixHex(hex: string, target: string, t: number): string {
   const a = hexToRgb(hex);
   const b = hexToRgb(target);
   return rgbToHex(a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t);
}

// pos 0 = lightest (white), 0.5 = pure, 1 = darkest (black)
export function shadeOf(base: string, pos: number): string {
   if (pos <= 0.5) return mixHex(base, "#ffffff", (0.5 - pos) / 0.5);
   return mixHex(base, "#000000", (pos - 0.5) / 0.5);
}

export function hexToHsl(hex: string): [number, number, number] {
   const [r, g, b] = hexToRgb(hex).map((v) => v / 255);
   const max = Math.max(r, g, b);
   const min = Math.min(r, g, b);
   const l = (max + min) / 2;
   let h = 0;
   let s = 0;
   if (max !== min) {
      const d = max - min;
      s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
      if (max === r) h = (g - b) / d + (g < b ? 6 : 0);
      else if (max === g) h = (b - r) / d + 2;
      else h = (r - g) / d + 4;
      h *= 60;
   }
   return [h, s, l];
}

export function hslToHex(h: number, s: number, l: number): string {
   h /= 360;
   let r: number;
   let g: number;
   let b: number;
   if (s === 0) {
      r = g = b = l;
   } else {
      const hue2rgb = (p: number, q: number, t: number) => {
         if (t < 0) t += 1;
         if (t > 1) t -= 1;
         if (t < 1 / 6) return p + (q - p) * 6 * t;
         if (t < 1 / 2) return q;
         if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
         return p;
      };
      const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
      const p = 2 * l - q;
      r = hue2rgb(p, q, h + 1 / 3);
      g = hue2rgb(p, q, h);
      b = hue2rgb(p, q, h - 1 / 3);
   }
   return rgbToHex(r * 255, g * 255, b * 255);
}

/** "#abc" | "abc" | "#AABBCC" -> "#AABBCC"; returns null when not a valid hex color. */
export function normalizeHex(input: string): string | null {
   let v = input.trim().replace(/^#/, "");
   if (/^[0-9a-fA-F]{3}$/.test(v))
      v = v
         .split("")
         .map((c) => c + c)
         .join("");
   if (!/^[0-9a-fA-F]{6}$/.test(v)) return null;
   return "#" + v.toUpperCase();
}

/**
 * Decompose a hex into the picker's internal model: the pure hue at 50% lightness
 * plus a position on the light/dark arc. shadeOf(base, lightPos) reproduces the hex.
 */
export function deriveFromHex(hex: string): { base: string; lightPos: number } {
   const [h, s, l] = hexToHsl(hex);
   return {
      base: hslToHex(h, s, 0.5),
      lightPos: Math.min(1, Math.max(0, 1 - l)),
   };
}
packages/react/src/use-controllable-state.ts
import * as React from "react";

/**
 * Controlled/uncontrolled state: when `prop` is provided the component follows it
 * and only reports changes through `onChange`; otherwise state is kept internally.
 */
export function useControllableState<T>(
   prop: T | undefined,
   defaultProp: T,
   onChange?: (value: T) => void
): [T, (next: T) => void] {
   const [internal, setInternal] = React.useState(defaultProp);
   const isControlled = prop !== undefined;
   const value = isControlled ? prop : internal;

   const onChangeRef = React.useRef(onChange);
   React.useEffect(() => {
      onChangeRef.current = onChange;
   });

   const setValue = React.useCallback(
      (next: T) => {
         if (!isControlled) setInternal(next);
         onChangeRef.current?.(next);
      },
      [isControlled]
   );

   return [value, setValue];
}
packages/react/src/style.css
/* bloom-color-picker — zero-dependency styles.
   Spring easings are pre-computed linear() curves matching the original
   motion springs (stiffness/damping noted on each). */

.bcp {
   /* Fallback timings for browsers without linear() support (Safari < 17.2,
      Chrome < 113, Firefox < 112): shorter durations, spring-ish beziers. */
   --bcp-dur-bloom: 450ms;
   --bcp-ease-bloom: cubic-bezier(0.22, 1, 0.36, 1);
   --bcp-dur-dish: 500ms;
   --bcp-ease-dish: cubic-bezier(0.34, 1.3, 0.64, 1);
   --bcp-dur-arc: 500ms;
   --bcp-ease-arc: cubic-bezier(0.25, 0.1, 0.25, 1);
   --bcp-dur-petal: 550ms;
   --bcp-ease-petal: cubic-bezier(0.22, 1, 0.36, 1);
   --bcp-dur-petal-hover: 400ms;
   --bcp-ease-petal-hover: cubic-bezier(0.34, 1.56, 0.64, 1);
   --bcp-dur-knob: 130ms;
   --bcp-ease-knob: cubic-bezier(0.25, 0.1, 0.25, 1);
   --bcp-dur-press: 300ms;
   --bcp-ease-press: cubic-bezier(0.34, 1.56, 0.64, 1);

   /* Focus-pull radii, kept as variables so they can be turned off where they
      are too expensive to be worth it — see the coarse-pointer block at the end
      of this file. `transform` and `opacity` are composited, but `blur()` forces
      a re-raster every frame, and these layers all animate at once. */
   --bcp-petal-blur-in: 12px;
   --bcp-petal-blur-out: 3px;
   --bcp-bloom-blur: 3px;
   --bcp-dish-blur: 8px;
   --bcp-arc-blur: 8px;

   --bcp-scale: 1;

   /* public theming variables — override any of these to restyle without
      touching the component's own classes. Defaults match the original look. */
   --bcp-color-focus: #4e72d6;
   --bcp-swatch-ring: #fff;
   --bcp-swatch-shadow: rgba(0, 0, 0, 0.08);
   --bcp-bloom-shadow: rgba(0, 0, 0, 0.12);
   --bcp-dish-bg: rgba(233, 234, 236, 0.8);
   --bcp-petal-shadow: rgba(0, 0, 0, 0.06);
   --bcp-input-bg: #f2f2f4;
   --bcp-input-border: #e4e4e9;
   --bcp-input-color: #8a8a92;
   --bcp-input-color-focus: #1a1a1a;

   position: relative;
   isolation: isolate;
   display: flex;
   align-items: center;
   justify-content: center;
}

/* dark theme — via an explicit theme="dark" prop (sets data-theme
   directly, applies regardless of the OS setting) or theme="auto"
   (no data-theme attribute) following prefers-color-scheme. Shadows get
   bumped opacity since a plain black shadow barely reads against an
   already-dark surrounding page. */
.bcp[data-theme="dark"] {
   --bcp-swatch-ring: rgba(255, 255, 255, 0.6);
   --bcp-swatch-shadow: rgba(0, 0, 0, 0.4);
   --bcp-bloom-shadow: rgba(0, 0, 0, 0.45);
   --bcp-petal-shadow: rgba(0, 0, 0, 0.18);
   --bcp-dish-bg: rgba(40, 40, 46, 0.28);
   --bcp-input-bg: #2a2a2f;
   --bcp-input-border: #3a3a40;
   --bcp-input-color: #a0a0a8;
   --bcp-input-color-focus: #f2f2f4;
}

/* theme="auto" (no data-theme attribute) — follow the OS setting instead.
   :not([data-theme="light"]) keeps an explicit theme="light" override
   winning even when the OS is dark. */
@media (prefers-color-scheme: dark) {
   .bcp:not([data-theme="light"]) {
      --bcp-swatch-ring: rgba(255, 255, 255, 0.6);
      --bcp-swatch-shadow: rgba(0, 0, 0, 0.4);
      --bcp-bloom-shadow: rgba(0, 0, 0, 0.45);
      --bcp-petal-shadow: rgba(0, 0, 0, 0.18);
      --bcp-dish-bg: rgba(40, 40, 46, 0.28);
      --bcp-input-bg: #2a2a2f;
      --bcp-input-border: #3a3a40;
      --bcp-input-color: #a0a0a8;
      --bcp-input-color-focus: #f2f2f4;
   }
}

/* Spring easings: pre-computed linear() curves matching the original motion
   springs (stiffness/damping noted on each). */
@supports (transition-timing-function: linear(0, 1)) {
   .bcp {
      /* bloom morph: stiffness 420, damping 30 */
      --bcp-dur-bloom: 656ms;
      --bcp-ease-bloom: linear(
         0.0001,
         0.0528,
         0.1699,
         0.311,
         0.4611,
         0.6007,
         0.7213,
         0.8196,
         0.8936,
         0.9496,
         0.9882,
         1.0127,
         1.0264,
         1.0323,
         1.0331,
         1.0307,
         1.0264,
         1.0215,
         1.0167,
         1.0122,
         1.0083,
         1.0053,
         1.0029,
         1.0013,
         1.0001,
         0.9994,
         0.999,
         0.9989,
         0.9989,
         0.999,
         0.9992,
         0.9993,
         0.9995,
         0.9996,
         0.9998,
         0.9998,
         0.9999,
         1,
         1,
         1,
         1
      );
      /* dish: stiffness 420, damping 26 */
      --bcp-dur-dish: 766ms;
      --bcp-ease-dish: linear(
         0.0001,
         0.069,
         0.226,
         0.4115,
         0.5942,
         0.7535,
         0.8825,
         0.9726,
         1.0307,
         1.0623,
         1.0743,
         1.0724,
         1.0627,
         1.049,
         1.035,
         1.0223,
         1.012,
         1.0041,
         0.999,
         0.996,
         0.9946,
         0.9944,
         0.995,
         0.9959,
         0.997,
         0.998,
         0.9989,
         0.9995,
         1,
         1.0002,
         1.0004,
         1.0004,
         1.0004,
         1.0003,
         1.0003,
         1.0002,
         1.0001,
         1.0001,
         1,
         1,
         1
      );
      /* arc: stiffness 300, damping 33 — near-critical, essentially no overshoot */
      --bcp-dur-arc: 592ms;
      --bcp-ease-arc: linear(
         0.0001,
         0.0315,
         0.0995,
         0.1898,
         0.2845,
         0.3821,
         0.4742,
         0.5555,
         0.6303,
         0.6934,
         0.7495,
         0.7969,
         0.8353,
         0.8682,
         0.8944,
         0.9166,
         0.9345,
         0.9484,
         0.9599,
         0.9688,
         0.976,
         0.9817,
         0.986,
         0.9895,
         0.992,
         0.9941,
         0.9956,
         0.9968,
         0.9977,
         0.9983,
         0.9988,
         0.9992,
         0.9995,
         0.9996,
         0.9998,
         0.9999,
         0.9999,
         1,
         1,
         1,
         1
      );
      /* petal entrance: stiffness 220, damping 22 */
      --bcp-dur-petal: 890ms;
      --bcp-ease-petal: linear(
         0.0001,
         0.0502,
         0.1608,
         0.3026,
         0.4465,
         0.5844,
         0.7021,
         0.8014,
         0.8777,
         0.9359,
         0.9762,
         1.0034,
         1.0194,
         1.0277,
         1.0303,
         1.0293,
         1.0263,
         1.0221,
         1.0177,
         1.0134,
         1.0097,
         1.0066,
         1.0041,
         1.0023,
         1.0009,
         1,
         0.9995,
         0.9992,
         0.9991,
         0.9991,
         0.9992,
         0.9993,
         0.9994,
         0.9996,
         0.9997,
         0.9998,
         0.9999,
         0.9999,
         1,
         1,
         1
      );
      /* petal hover: stiffness 400, damping 18 */
      --bcp-dur-petal-hover: 1045ms;
      --bcp-ease-petal-hover: linear(
         0.0001,
         0.1203,
         0.3818,
         0.6816,
         0.9329,
         1.1042,
         1.1891,
         1.2018,
         1.1665,
         1.1091,
         1.0502,
         1.0027,
         0.972,
         0.9593,
         0.9599,
         0.9688,
         0.9812,
         0.9928,
         1.0016,
         1.0067,
         1.0085,
         1.0078,
         1.0057,
         1.0032,
         1.0009,
         0.9993,
         0.9985,
         0.9983,
         0.9985,
         0.999,
         0.9995,
         0.9999,
         1.0002,
         1.0003,
         1.0003,
         1.0003,
         1.0002,
         1.0001,
         1,
         0.9999,
         1
      );
      /* just a plain fast ease — no spring/overshoot for the default knob */
      --bcp-dur-knob: 130ms;
      --bcp-ease-knob: cubic-bezier(0.25, 0.1, 0.25, 1);
      /* swatch press: stiffness 500, damping 24 */
      --bcp-dur-press: 817ms;
      --bcp-ease-press: linear(
         0.0001,
         0.0946,
         0.3006,
         0.5407,
         0.7574,
         0.9328,
         1.0507,
         1.1143,
         1.1343,
         1.1245,
         1.0981,
         1.0668,
         1.0363,
         1.0117,
         0.9948,
         0.9854,
         0.982,
         0.9829,
         0.9862,
         0.9904,
         0.9946,
         0.998,
         1.0005,
         1.0018,
         1.0024,
         1.0023,
         1.0019,
         1.0014,
         1.0008,
         1.0003,
         1,
         0.9998,
         0.9997,
         0.9997,
         0.9997,
         0.9998,
         0.9999,
         0.9999,
         1,
         1,
         1
      );
   }
}

/* motion="bouncy" — the same springs with lower damping, more overshoot.
   Fallback cubic-bezier for browsers without linear() support first, then
   the pre-computed linear() curves (stiffness/damping noted on each). */
.bcp[data-motion="bouncy"] {
   --bcp-dur-bloom: 520ms;
   --bcp-ease-bloom: cubic-bezier(0.3, 1.7, 0.4, 1);
   --bcp-dur-dish: 560ms;
   --bcp-ease-dish: cubic-bezier(0.3, 1.8, 0.4, 1);
   --bcp-dur-arc: 620ms;
   --bcp-ease-arc: cubic-bezier(0.3, 1.9, 0.4, 1);
   --bcp-dur-petal-hover: 440ms;
   --bcp-ease-petal-hover: cubic-bezier(0.3, 1.9, 0.4, 1);
   --bcp-dur-knob: 400ms;
   --bcp-ease-knob: cubic-bezier(0.3, 1.9, 0.4, 1);
   --bcp-dur-press: 340ms;
   --bcp-ease-press: cubic-bezier(0.3, 1.9, 0.4, 1);
}

@supports (transition-timing-function: linear(0, 1)) {
   .bcp[data-motion="bouncy"] {
      /* bloom: stiffness 440, damping 24 */
      --bcp-dur-bloom: 856ms;
      --bcp-ease-bloom: linear(
         0.0001,
         0.0907,
         0.2873,
         0.5109,
         0.7237,
         0.8937,
         1.0116,
         1.0799,
         1.1077,
         1.108,
         1.0912,
         1.0669,
         1.042,
         1.021,
         1.0049,
         0.9945,
         0.9892,
         0.9877,
         0.9888,
         0.9911,
         0.9939,
         0.9965,
         0.9986,
         1.0001,
         1.0009,
         1.0013,
         1.0013,
         1.0011,
         1.0008,
         1.0005,
         1.0003,
         1.0001,
         0.9999,
         0.9999,
         0.9998,
         0.9999,
         0.9999,
         0.9999,
         1,
         1,
         1
      );
      /* dish: stiffness 440, damping 21 */
      --bcp-dur-dish: 975ms;
      --bcp-ease-dish: linear(
         0.0001,
         0.1163,
         0.3647,
         0.6362,
         0.8752,
         1.043,
         1.1343,
         1.1615,
         1.1456,
         1.1066,
         1.0618,
         1.0232,
         0.995,
         0.9792,
         0.974,
         0.976,
         0.9821,
         0.9894,
         0.9959,
         1.0005,
         1.0032,
         1.0042,
         1.0039,
         1.003,
         1.0018,
         1.0008,
         1,
         0.9995,
         0.9993,
         0.9994,
         0.9995,
         0.9997,
         0.9999,
         1,
         1.0001,
         1.0001,
         1.0001,
         1.0001,
         1.0001,
         1,
         1
      );
      /* arc: stiffness 320, damping 17 */
      --bcp-dur-arc: 1156ms;
      --bcp-ease-arc: linear(
         0.0001,
         0.1182,
         0.3739,
         0.6556,
         0.902,
         1.0727,
         1.1615,
         1.1821,
         1.1573,
         1.1093,
         1.0573,
         1.0137,
         0.9846,
         0.9697,
         0.9669,
         0.9721,
         0.9809,
         0.9904,
         0.9981,
         1.0032,
         1.0057,
         1.006,
         1.0049,
         1.0033,
         1.0016,
         1.0002,
         0.9993,
         0.9989,
         0.9989,
         0.9991,
         0.9994,
         0.9997,
         1,
         1.0001,
         1.0002,
         1.0002,
         1.0002,
         1.0001,
         1,
         1,
         1
      );
      /* petal-hover: stiffness 420, damping 15 */
      --bcp-dur-petal-hover: 1296ms;
      --bcp-ease-petal-hover: linear(
         0.0001,
         0.1912,
         0.5828,
         0.9594,
         1.208,
         1.2898,
         1.2416,
         1.1319,
         1.0191,
         0.9434,
         0.9162,
         0.928,
         0.9591,
         0.9923,
         1.0152,
         1.0241,
         1.0215,
         1.0126,
         1.0029,
         0.9959,
         0.9931,
         0.9936,
         0.9961,
         0.999,
         1.0011,
         1.002,
         1.0019,
         1.0012,
         1.0004,
         0.9997,
         0.9994,
         0.9994,
         0.9996,
         0.9999,
         1.0001,
         1.0002,
         1.0002,
         1.0001,
         1,
         1,
         1
      );
      /* knob: stiffness 420, damping 18 */
      --bcp-dur-knob: 1129ms;
      --bcp-ease-knob: linear(
         0.0001,
         0.1436,
         0.4523,
         0.7739,
         1.0284,
         1.1715,
         1.2144,
         1.1852,
         1.1196,
         1.0504,
         0.9952,
         0.9638,
         0.9541,
         0.96,
         0.9736,
         0.9888,
         1.0006,
         1.0076,
         1.0098,
         1.0087,
         1.0057,
         1.0025,
         0.9999,
         0.9984,
         0.9979,
         0.9981,
         0.9987,
         0.9994,
         1,
         1.0003,
         1.0005,
         1.0004,
         1.0003,
         1.0001,
         1,
         0.9999,
         0.9999,
         0.9999,
         0.9999,
         1,
         1
      );
      /* press: stiffness 520, damping 19 */
      --bcp-dur-press: 1041ms;
      --bcp-ease-press: linear(
         0.0001,
         0.1541,
         0.4765,
         0.8143,
         1.0701,
         1.2069,
         1.2342,
         1.1875,
         1.1084,
         1.0309,
         0.9746,
         0.948,
         0.9459,
         0.9592,
         0.9783,
         0.9958,
         1.0076,
         1.0127,
         1.0124,
         1.0089,
         1.0043,
         1.0004,
         0.9979,
         0.9969,
         0.9972,
         0.9981,
         0.9992,
         1.0001,
         1.0006,
         1.0007,
         1.0006,
         1.0004,
         1.0001,
         1,
         0.9999,
         0.9998,
         0.9999,
         0.9999,
         1,
         1,
         1
      );
   }
}

/* Swatch-sized anchor the bloom overlays so it opens where the swatch was */
.bcp__slot {
   position: relative;
   width: calc(50px * var(--bcp-scale));
   height: calc(50px * var(--bcp-scale));
   flex-shrink: 0;
}

/* --- Closed swatch --- */
.bcp__swatch {
   width: calc(50px * var(--bcp-scale));
   height: calc(50px * var(--bcp-scale));
   border-radius: 50%;
   border: none;
   padding: 0;
   cursor: pointer;
   flex-shrink: 0;
   box-shadow: 0 2px 8px var(--bcp-swatch-shadow);
   outline: calc(1.5px * var(--bcp-scale)) solid var(--bcp-swatch-ring);
   transition:
      transform var(--bcp-dur-press) var(--bcp-ease-press),
      background-color 0.25s ease-out;
   animation: bcp-swatch-in 0.18s ease-out;
}

.bcp__swatch--pressing {
   transform: scale(0.85);
}

.bcp__swatch:disabled {
   cursor: not-allowed;
   opacity: 0.55;
}

/* --- Hex input ---
   The wrap is what's actually positioned/z-indexed (outside the swatch's
   box, so it never shifts what the bloom considers "center" — the bloom
   always opens from the swatch). It shrink-wraps to the input's real
   rendered width (no JS measurement) since only `left` is set, not `right`.
   Stays mounted (not hidden) while open. z-index sits below the arc(0),
   bloom(1), and petals(2) so the bloom opens over it, not under it. */
.bcp__input-wrap {
   position: absolute;
   z-index: -1;
   top: 50%;
   left: 0;
   transform: translateY(-50%);
   padding-left: calc(50px * var(--bcp-scale) + 14px * var(--bcp-scale));
}

.bcp__input {
   border: 1px solid var(--bcp-input-border);
   background: var(--bcp-input-bg);
   border-radius: calc(16px * var(--bcp-scale));
   padding: calc(10px * var(--bcp-scale)) calc(14px * var(--bcp-scale));
   font-family: var(--bcp-font-input, ui-monospace, "SF Mono", "Cascadia Code", monospace);
   font-size: calc(21px * var(--bcp-scale));
   font-weight: 400;
   color: var(--bcp-input-color);
   transition: color 0.15s ease-out;
   animation: bcp-swatch-in 0.18s ease-out;
}

.bcp__input:focus {
   color: var(--bcp-input-color-focus);
}

.bcp__input:disabled {
   cursor: not-allowed;
   opacity: 0.55;
}

/* grouped: swatch + input share one pill background instead of two boxes.
   The wrap's padding-left already reaches back under the swatch, so the
   shared background just needs its own bg/border/height — the swatch
   itself (a later, higher z-indexed sibling) paints over the left end. */
.bcp[data-input-variant="grouped"] .bcp__input-wrap {
   display: flex;
   align-items: center;
   left: calc(-12px * var(--bcp-scale));
   height: calc(70px * var(--bcp-scale));
   padding-left: calc(50px * var(--bcp-scale) + 16px * var(--bcp-scale));
   padding-right: calc(10px * var(--bcp-scale));
   background: var(--bcp-input-bg);
   border: 1px solid var(--bcp-input-border);
   border-radius: calc(22px * var(--bcp-scale));
}

.bcp[data-input-variant="grouped"] .bcp__input {
   background: transparent;
   border-color: transparent;
   padding: calc(16px * var(--bcp-scale)) calc(6px * var(--bcp-scale));
   font-size: calc(29px * var(--bcp-scale));
}

/* The input's own box sits smaller/offset inside the visible pill in grouped
   mode, so a focus ring on the input itself doesn't trace the pill's actual
   shape. Ring the wrap instead (its real border-radius) via :focus-within. */
.bcp[data-input-variant="grouped"] .bcp__input:focus-visible {
   outline: none;
}

.bcp[data-input-variant="grouped"] .bcp__input-wrap:focus-within {
   outline: 2px solid var(--bcp-color-focus);
   outline-offset: 2px;
}

/* bouncy: the swatch springs back in (scale overshoot) when the bloom
   closes, reusing the press spring so it matches the swatch's own tap feel */
.bcp[data-motion="bouncy"] .bcp__swatch {
   animation: bcp-swatch-in-bouncy var(--bcp-dur-press) var(--bcp-ease-press);
}

@keyframes bcp-swatch-in-bouncy {
   from {
      opacity: 0;
      transform: scale(0.8);
   }
   to {
      opacity: 1;
      transform: scale(1);
   }
}

@keyframes bcp-swatch-in {
   from {
      opacity: 0;
   }
   to {
      opacity: 1;
   }
}

/* --- Dahlia bloom --- */
.bcp__bloom {
   position: absolute;
   z-index: 1;
   top: 50%;
   left: 50%;
   width: calc(280px * var(--bcp-scale));
   height: calc(280px * var(--bcp-scale));
   margin: calc(-140px * var(--bcp-scale)) 0 0 calc(-140px * var(--bcp-scale));
   border-radius: 50%;
   box-sizing: border-box;
   box-shadow: 0 8px 28px var(--bcp-bloom-shadow);
   /* opens from swatch size (50/280) at the same center */
   animation:
      bcp-bloom-in var(--bcp-dur-bloom) var(--bcp-ease-bloom) backwards,
      bcp-unblur-3 0.26s ease-out backwards;
   transition: background-color 0.25s ease-out;
}

@keyframes bcp-bloom-in {
   from {
      transform: scale(0.1786);
   }
   to {
      transform: scale(1);
   }
}

@keyframes bcp-unblur-3 {
   from {
      filter: blur(var(--bcp-bloom-blur));
   }
   to {
      filter: blur(0px);
   }
}

.bcp__bloom--closing {
   /* keyframe (not transition) so the exit reliably replaces the entrance animation */
   animation: bcp-bloom-out 0.22s cubic-bezier(0.42, 0, 1, 1) forwards;
}

@keyframes bcp-bloom-out {
   from {
      transform: scale(1);
      opacity: 1;
      filter: blur(0px);
   }
   to {
      transform: scale(0.1786);
      opacity: 0;
      filter: blur(var(--bcp-bloom-blur));
   }
}

.bcp__dish {
   position: absolute;
   inset: calc(20px * var(--bcp-scale));
   border-radius: 50%;
   background: var(--bcp-dish-bg);
   overflow: hidden;
   animation: bcp-dish-in var(--bcp-dur-dish) var(--bcp-ease-dish) backwards;
}

/* The dish sits inside the bloom, so its scale multiplies with the bloom's own
   0.1786 → 1. Starting from 0.4 meant an effective 0.07 on the first frame,
   while the petals — siblings of the bloom, not children — animate at full size
   from the start, so the dish read as arriving after them. It now starts close
   to full and lets the parent's growth carry it. */
@keyframes bcp-dish-in {
   from {
      transform: scale(0.86);
      opacity: 0;
      filter: blur(var(--bcp-dish-blur));
   }
   to {
      transform: scale(1);
      opacity: 1;
      filter: blur(0px);
   }
}

/* --- Brightness arc --- */
.bcp__arc {
   position: absolute;
   top: 50%;
   left: 50%;
   margin-top: calc(-180px * var(--bcp-scale));
   margin-left: calc(-180px * var(--bcp-scale));
   transform-origin: center;
   z-index: 0;
   pointer-events: none;
   overflow: visible;
   animation: bcp-arc-in var(--bcp-dur-arc) var(--bcp-ease-arc) 0.28s backwards;
}

@keyframes bcp-arc-in {
   from {
      opacity: 0;
      transform: scale(0.3);
      filter: blur(var(--bcp-arc-blur));
   }
   to {
      opacity: 1;
      transform: scale(1);
      filter: blur(0px);
   }
}

.bcp__arc--closing {
   animation: bcp-arc-out 0.16s cubic-bezier(0.42, 0, 1, 1) forwards;
}

@keyframes bcp-arc-out {
   from {
      opacity: 1;
      transform: scale(1);
      filter: blur(0px);
   }
   to {
      opacity: 0;
      transform: scale(0.3);
      filter: blur(var(--bcp-arc-blur));
   }
}

.bcp__knob {
   pointer-events: auto;
}

.bcp__knob-halo {
   r: 14.5px;
   filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.25));
   transition: r var(--bcp-dur-knob) var(--bcp-ease-knob);
}

.bcp__knob-core {
   r: 11.5px;
   transition: r var(--bcp-dur-knob) var(--bcp-ease-knob);
}

.bcp--dragging .bcp__knob-halo {
   r: 16.5px;
}

.bcp--dragging .bcp__knob-core {
   r: 13.5px;
}

/* --- Petals --- */
.bcp__petal {
   position: absolute;
   z-index: 2;
   border: none;
   border-radius: 50%;
   padding: 0;
   cursor: pointer;
   box-shadow: 0 1px 4px var(--bcp-petal-shadow);
   /* spiral entrance from the center, staggered per petal */
   animation: bcp-petal-in var(--bcp-dur-petal) var(--bcp-ease-petal) var(--bcp-petal-delay)
      backwards;
   transition: transform var(--bcp-dur-petal-hover) var(--bcp-ease-petal-hover);
}

@keyframes bcp-petal-in {
   from {
      transform: translate(var(--bcp-from-x), var(--bcp-from-y)) scale(1.5);
      opacity: 0;
      filter: blur(var(--bcp-petal-blur-in));
   }
   to {
      transform: translate(0, 0) scale(1);
      opacity: 1;
      filter: blur(0px);
   }
}

.bcp__petal--hovered {
   transform: scale(1.18);
}

.bcp__petal:active {
   transform: scale(0.95);
   transition-duration: 0.1s;
}

/* close phase 2: all rings converge back to the center */
.bcp__petal--home {
   animation: bcp-petal-out 0.34s ease-in-out forwards;
   transition: none;
   pointer-events: none;
}

@keyframes bcp-petal-out {
   from {
      transform: translate(0, 0) scale(1);
      opacity: 1;
      filter: blur(0px);
   }
   to {
      transform: translate(var(--bcp-from-x), var(--bcp-from-y)) scale(1);
      opacity: 0;
      filter: blur(var(--bcp-petal-blur-out));
   }
}

/* motion="none" is documented as an instant open/close — "a quick uniform
   fade, not an instant snap" — but the override at the end of this file only
   flattens durations, easings and delays; the keyframes still run the full
   spiral. With the stagger gone, all 19 petals fly in from the centre on the
   same frame inside 160ms, and the arc scales from 0.3 alongside them, which is
   the most concentrated transform work this component can produce. These give
   it the fade it actually promises — opacity only, so element-agnostic. */
@keyframes bcp-fade-in {
   from {
      opacity: 0;
   }
   to {
      opacity: 1;
   }
}

@keyframes bcp-fade-out {
   from {
      opacity: 1;
   }
   to {
      opacity: 0;
   }
}

/* Touch devices drop the focus-pull entirely. Opening the bloom animates blur
   on 22 layers at once — 19 petals, plus the bloom, the dish and the 360px arc,
   which is an SVG being blurred while it scales from 0.3. Unlike the transform
   and opacity beside them, blur() can't be composited: every frame re-rasterises
   the layer and runs a Gaussian over it. Desktop GPUs absorb that; phone GPUs
   drop frames on it.

   Keyed on pointer type rather than width, so a narrowed desktop window keeps
   the full effect and only the devices that actually struggle take the cheap
   path. Everything still spirals, scales and fades; what goes is the decoration
   that costs the most to paint and reads the least in motion — the focus-pull
   here, plus the petals' shadow and ring below. */
@media (hover: none) and (pointer: coarse) {
   .bcp {
      --bcp-petal-blur-in: 0px;
      /* The close keeps a softened version rather than dropping to 0: it is a
         quarter of the entrance radius over a fixed 340ms, and without any of
         it the petals snap back to the centre instead of collapsing. Far
         cheaper than the entrance, which is what actually caused the jank. */
      --bcp-petal-blur-out: 1.5px;
      --bcp-bloom-blur: 0px;
      --bcp-dish-blur: 0px;
      --bcp-arc-blur: 0px;
   }

   /* The rest of a petal's cost is paint, not motion: a blurred shadow and a
      ring built from a nested color-mix(), on a 50%-radius box — repeated 19
      times, all scaling at once. Both are decoration a phone can do without;
      the petals stay legible on their own fills. The ring is set inline from
      JS, so only !important can take it off — hence :not(:focus-visible), so
      this can't also suppress the focus ring for anyone pairing a keyboard
      with a touch device. */
   .bcp__petal {
      box-shadow: none;
   }

   .bcp__petal:not(:focus-visible) {
      outline: none !important;
   }

   /* Scoped to touch, where the compressed motion reads as a jerk. Desktop
      keeps its current motion="none" look until that's a deliberate change.
      The arc is the worst of them: a 360px SVG scaling from 0.3, so its paths
      re-rasterise the whole way in. */
   .bcp[data-motion="none"] .bcp__petal,
   .bcp[data-motion="none"] .bcp__arc {
      animation-name: bcp-fade-in;
   }

   .bcp[data-motion="none"] .bcp__petal--home,
   .bcp[data-motion="none"] .bcp__arc--closing {
      animation-name: bcp-fade-out;
   }
}

@media (prefers-reduced-motion: reduce) {
   .bcp,
   .bcp * {
      animation-duration: 0.01ms !important;
      animation-delay: 0ms !important;
      transition-duration: 0.01ms !important;
      transition-delay: 0ms !important;
   }
}

/* motion="none" — a quick uniform fade, not an instant snap. Overrides every
   spring/bounce curve and stagger delay with one plain ease-out, independent
   of the visitor's OS motion setting. */
.bcp[data-motion="none"],
.bcp[data-motion="none"] * {
   animation-duration: 160ms !important;
   animation-timing-function: ease-out !important;
   animation-delay: 0ms !important;
   transition-duration: 160ms !important;
   transition-timing-function: ease-out !important;
   transition-delay: 0ms !important;
}