Color Picker
HSV color picker with a 2D saturation/brightness area, hue and optional alpha sliders whose thumbs spring to a clicked point and follow drags 1:1, a validated hex field, preset swatches with a gliding selection ring and a ColorPickerPopover trigger mode; keyboard accessible, dependency-free color math.
Preview
Install
Add it with the shadcn CLI, or copy the source manually.
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
"use client";
import { type KeyboardEvent, type PointerEvent, useCallback, useRef, useState } from "react";
import { capturePointer, releasePointer } from "@/lib/touch";
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
/** Nearest legal value on [min, max] for the given step. max counts as a
* candidate when the step does not divide the range, so a pointer near the end
* does not snap back onto the last whole step. */
export function snapSliderValue(next: number, min: number, max: number, step: number): number {
// Neither case has a grid to walk. An empty range has exactly one legal
// point, and a non-positive step only needs a clamp, which also keeps the
// division below away from zero.
if (!(max > min)) return min;
if (!(step > 0)) return clamp(next, min, max);
const whole = Math.floor(Number(((max - min) / step).toFixed(6)));
const lastWhole = Number((min + whole * step).toFixed(6));
const toGrid = clamp(Math.round((next - min) / step) * step + min, min, lastWhole);
const snapped =
lastWhole < max && Math.abs(next - max) <= Math.abs(next - toGrid) ? max : toGrid;
return Number(snapped.toFixed(6));
}
export interface SliderOptions {
value?: number;
defaultValue?: number;
onValueChange?: (value: number) => void;
min?: number;
max?: number;
step?: number;
disabled?: boolean;
"aria-label"?: string;
/** Announced instead of the raw number — pass one when the value carries a
* unit or a suffix ("72.5 kg", "35%"); a bare number needs no valueText. */
formatValueText?: (value: number) => string;
}
/**
* Shared value + input plumbing for slider designs: controlled/uncontrolled
* value, step snapping, pointer-capture drag along a track and arrow-key
* control. Visuals and motion live in the component; this only owns the number.
*/
export function useSlider({
value,
defaultValue = 0,
onValueChange,
min = 0,
max = 100,
step = 1,
disabled = false,
"aria-label": ariaLabel,
formatValueText,
}: SliderOptions) {
const trackRef = useRef<HTMLDivElement>(null);
const sliderEl = useRef<HTMLElement | null>(null);
// The state drives visuals. Move reads this ref instead, so the first
// pointermove after pointerdown does not have to wait on a re-render.
const draggingRef = useRef(false);
const [internal, setInternal] = useState(defaultValue);
const [dragging, setDragging] = useState(false);
const controlled = value !== undefined;
// Collapse inverted or empty ranges and non-positive steps here, so that
// percent, ticks and the keyboard maths never divide by zero or walk a
// NaN grid.
const lo = min;
const hi = max > min ? max : min;
const stride = step > 0 ? step : 1;
const current = clamp(controlled ? value : internal, lo, hi);
const percent = hi > lo ? ((current - lo) / (hi - lo)) * 100 : 0;
const commit = useCallback(
(next: number) => {
const clean = snapSliderValue(next, lo, hi, stride);
if (!controlled) setInternal(clean);
onValueChange?.(clean);
},
[controlled, onValueChange, lo, hi, stride],
);
const commitFromX = useCallback(
(clientX: number) => {
const rect = trackRef.current?.getBoundingClientRect();
if (!rect || rect.width === 0) return;
const ratio = clamp((clientX - rect.left) / rect.width, 0, 1);
commit(lo + ratio * (hi - lo));
},
[commit, lo, hi],
);
const onPointerDown = useCallback(
(event: PointerEvent<HTMLDivElement>) => {
if (disabled) return;
// Start the drag first: capture is a convenience, and a browser that
// refuses it — or a test DOM that has no pointer capture at all — must
// not take the drag down with it.
draggingRef.current = true;
setDragging(true);
capturePointer(event.currentTarget, event.pointerId);
// A click on the track should land keyboard focus on the handle.
sliderEl.current?.focus({ preventScroll: true });
commitFromX(event.clientX);
},
[disabled, commitFromX],
);
const onPointerMove = useCallback(
(event: PointerEvent<HTMLDivElement>) => {
if (!draggingRef.current || disabled) return;
commitFromX(event.clientX);
},
[disabled, commitFromX],
);
const endDrag = useCallback((event: PointerEvent<HTMLDivElement>) => {
releasePointer(event.currentTarget, event.pointerId);
draggingRef.current = false;
setDragging(false);
}, []);
const onKeyDown = useCallback(
(event: KeyboardEvent<HTMLElement>) => {
if (disabled) return;
const map: Record<string, number> = {
ArrowRight: current + stride,
ArrowUp: current + stride,
ArrowLeft: current - stride,
ArrowDown: current - stride,
PageUp: current + stride * 10,
PageDown: current - stride * 10,
Home: lo,
End: hi,
};
if (event.key in map) {
event.preventDefault();
commit(map[event.key]);
}
},
[disabled, current, stride, lo, hi, commit],
);
return {
current,
percent,
dragging,
min: lo,
max: hi,
step: stride,
commit,
/** Pointer handlers for the track element — drag anywhere on it. */
trackProps: {
ref: trackRef,
onPointerDown,
onPointerMove,
onPointerUp: endDrag,
onPointerCancel: endDrag,
onLostPointerCapture: endDrag,
},
/** ARIA + keyboard props for the focusable slider element. */
sliderProps: {
// Callback keeps the handle typed across button/div/motion hosts.
ref: (node: HTMLElement | null) => {
sliderEl.current = node;
},
role: "slider" as const,
tabIndex: disabled ? -1 : 0,
"aria-label": ariaLabel,
"aria-valuemin": lo,
"aria-valuemax": hi,
"aria-valuenow": current,
"aria-valuetext": formatValueText?.(current),
"aria-disabled": disabled || undefined,
onKeyDown,
},
};
}
// Shared touch primitives. iOS and iPadOS run their own gestures on top of the
// page — the long-press selection callout and the selection it drags in with
// it — and they win: once the platform claims a touch it cancels ours
// mid-gesture, so a press-and-hold or a drag simply dies. Surfaces that own
// their gesture have to opt out.
//
// What the two classes below cover, precisely:
// - `-webkit-touch-callout: none` stops iOS's long-press callout. WebKit-only:
// it is not a property other engines have, so it is inert everywhere else.
// - `user-select: none` stops the long-press selection on every engine,
// Android included, and stops a drag from painting a selection under the
// cursor. It is inherited, so it reaches every descendant — which is why the
// two classes differ only in whether they apply it unconditionally.
// What neither covers:
// - Chrome for Android's long-press menu on a link or an image. No CSS
// suppresses it; a gesture surface that wraps one needs its own
// `onContextMenu` with `preventDefault()`.
// - The native drag of an `<img>` or `<a>` descendant. `-webkit-user-drag` is
// not inherited and plain divs and buttons are not drag sources, so setting
// it on the surface does nothing — the child itself needs `draggable={false}`.
/**
* Classes for a surface that *is* the control: a thumb, a drum, a stage, a
* handle, a hold button. Selection is suppressed on every input, because a
* drag that highlights the control's own label is wrong on a mouse too.
* Compose with `touch-none` when the surface also owns the scroll axis — leave
* it off when the page must still scroll from there.
*/
export const TOUCH_GESTURE_CLASS = "select-none [-webkit-touch-callout:none]";
/**
* The same opt-out for a gesture surface that wraps content the consumer owns:
* a scroller, a context-menu trigger, a sheet header, a list row. Selection is
* suppressed only where the platform runs its own press gestures — a coarse
* pointer — so a mouse user can still select and copy that content. If the
* gesture itself would paint a selection under the cursor, add `select-none`
* for the duration of the gesture rather than reaching for
* `TOUCH_GESTURE_CLASS`.
*
* `pointer: coarse` describes the *primary* pointer and nothing else, so a
* hybrid machine reads it wrong in both directions: a tablet with a mouse
* plugged in keeps touch as primary and loses mouse selection, and a laptop
* with a touchscreen keeps the mouse as primary and leaves selection live
* under a finger. No media query can answer per interaction — the query is
* about the device, and the question is about the gesture in progress. The
* default stays here because it is right on the machines that are one thing or
* the other, and losing a selection is a nuisance; where the miss costs a
* *gesture* instead, the surface pairs it with `holdSelection` on the press.
*/
export const TOUCH_GESTURE_CONTENT_CLASS =
"[-webkit-touch-callout:none] pointer-coarse:select-none";
/**
* Grow a small control's *hit area* to at least 44×44px (WCAG 2.5.5, Apple
* HIG) on a coarse pointer, without changing what it looks like or the space
* it takes in layout. An invisible `::after` box, centered on the control and
* `max(100%, 2.75rem)` on each axis, catches the taps: a 32px icon button
* reaches 6px past each edge, a wide 40px button only grows vertically, and a
* control already 44px or larger is left as it is. Hit-testing a pseudo
* element lands on its owner, so handlers, focus and `:active` all behave.
*
* Mouse and trackpad rendering is untouched — nothing here applies outside
* `pointer: coarse`. Requirements on the element it goes on:
* - It must be the containing block: add `relative` (and keep it mergeable, so
* a consumer's `absolute` still wins through `cn`).
* - It must not clip itself (`overflow-hidden`), or the overhang is clipped
* away along with its hit area.
* - It must render children: `<input>` and other void elements have no
* `::after`. Put it on the wrapper or label instead.
* - It must not already use `::after` for something visible.
*
* Centered 44px means two 32px buttons with a 4px gap overlap by 8px; the
* later one wins that strip, which still ends well short of the earlier
* button's visual center. Below a ~32px pitch the later neighbour starts
* eating into that center — use `TOUCH_TARGET_Y_CLASS` for such dense rows.
*
* The overhang is real geometry: inside a scroll container it counts as
* scrollable overflow, so a control flush against a scroller's edge can add a
* few pixels of scroll on touch screens. Give such a scroller a little padding
* on that side.
*/
export const TOUCH_TARGET_CLASS =
"pointer-coarse:after:absolute pointer-coarse:after:top-1/2 pointer-coarse:after:left-1/2 pointer-coarse:after:size-[max(100%,2.75rem)] pointer-coarse:after:-translate-x-1/2 pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']";
/**
* `TOUCH_TARGET_CLASS` on the vertical axis only, for controls packed tighter
* than ~32px apart in a row (pagination dots, dense swatches), where growing
* sideways would hand most of each control to its neighbour. Same rules.
*/
export const TOUCH_TARGET_Y_CLASS =
"pointer-coarse:after:absolute pointer-coarse:after:inset-x-0 pointer-coarse:after:top-1/2 pointer-coarse:after:h-[max(100%,2.75rem)] pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']";
/**
* Suppress selection on `element` for as long as a gesture is running on it,
* whatever the primary pointer of the machine happens to be. Returns the
* release. Inline, so it wins over the class above and is gone again the
* moment the gesture ends.
*
* For the press gestures a native selection would otherwise steal — a
* long-press that opens a menu. Elsewhere prefer the classes: a surface that
* takes selection away for the whole session is a surface whose text nobody
* can copy.
*/
export function holdSelection(element: HTMLElement) {
element.style.setProperty("user-select", "none");
element.style.setProperty("-webkit-user-select", "none");
return () => {
element.style.removeProperty("user-select");
element.style.removeProperty("-webkit-user-select");
};
}
/**
* Pointer capture, best effort. WebKit throws `NotFoundError` when the pointer
* is already gone by the time the handler runs — routine on iOS, where the
* system can claim the touch first — and an uncaught throw takes the rest of
* the handler, the gesture included, down with it. Touch pointers carry
* implicit capture anyway, so losing it is never fatal.
*/
export function capturePointer(element: Element, pointerId: number) {
try {
element.setPointerCapture(pointerId);
} catch {
// Pointer is no longer active — implicit capture still applies on touch.
}
}
/** Release a capture taken with `capturePointer`, ignoring a stale pointer. */
export function releasePointer(element: Element, pointerId: number) {
try {
if (element.hasPointerCapture(pointerId)) {
element.releasePointerCapture(pointerId);
}
} catch {
// Capture was already dropped by the browser.
}
}
/**
* Whether this event came from a pointer that is *hovering*: not a touch, and
* not currently pressed. Which input the user is holding right now is not
* something a device capability can answer — a touchscreen laptop hovers and
* taps, and iPadOS reports a fine hovering pointer for a finger — so both
* paths stay live and each handler branches on the event it was given.
*
* A pen resting on the glass is making contact, not hovering: `buttons` is the
* tell, and it sends a pen tap down the same route a finger takes.
*
* This answers what an *enter* asks. A leave is the other half of a pair and
* has to be read against the enter that started it — `useHoverGesture` in
* `lib/hooks/use-hover-gesture` does that, and hover surfaces should use it
* rather than asking this question twice.
*/
export const isHoveringPointer = (event: {
pointerType: string;
buttons: number;
}) => event.pointerType !== "touch" && event.buttons === 0;
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
"use client";
// karaui.kamisama-v0.my.id/components/motion/color-picker
import { Pipette } from "lucide-react";
import {
animate,
type MotionValue,
motion,
useMotionValue,
useReducedMotion,
} from "motion/react";
import {
type KeyboardEvent,
type PointerEvent,
type RefObject,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import {
MorphPopover,
MorphPopoverContent,
MorphPopoverTrigger,
} from "@/components/motion/popover-morph";
import { EASE_OUT, SPRING_LAYOUT, SPRING_MOUSE } from "@/lib/ease";
import { useSlider } from "@/lib/hooks/use-slider";
import { capturePointer, releasePointer, TOUCH_GESTURE_CLASS } from "@/lib/touch";
import { cn } from "@/lib/utils";
/* -------------------------------------------------------------------------- */
/* Color math */
/* -------------------------------------------------------------------------- */
/** Hue 0–360, saturation/value/alpha 0–1. */
export interface HSVA {
h: number;
s: number;
v: number;
a: number;
}
interface RGBA {
r: number;
g: number;
b: number;
a: number;
}
const clamp = (n: number, lo: number, hi: number) =>
Math.min(hi, Math.max(lo, n));
export function hsvToRgb({ h, s, v, a }: HSVA): RGBA {
const f = (n: number) => {
const k = (n + h / 60) % 6;
return v - v * s * Math.max(0, Math.min(k, 4 - k, 1));
};
return {
r: Math.round(f(5) * 255),
g: Math.round(f(3) * 255),
b: Math.round(f(1) * 255),
a,
};
}
/** `fallbackHue` keeps the hue when the color is gray, where hue is undefined. */
export function rgbToHsv({ r, g, b, a }: RGBA, fallbackHue = 0): HSVA {
const rn = r / 255;
const gn = g / 255;
const bn = b / 255;
const max = Math.max(rn, gn, bn);
const min = Math.min(rn, gn, bn);
const d = max - min;
let h = fallbackHue;
if (d !== 0) {
if (max === rn) h = ((gn - bn) / d) % 6;
else if (max === gn) h = (bn - rn) / d + 2;
else h = (rn - gn) / d + 4;
h *= 60;
if (h < 0) h += 360;
}
return { h, s: max === 0 ? 0 : d / max, v: max, a };
}
/** Accepts `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`, with or without `#`. */
export function parseHex(input: string): RGBA | null {
let hex = input.trim().replace(/^#/, "").toLowerCase();
if (!/^[0-9a-f]+$/.test(hex) || ![3, 4, 6, 8].includes(hex.length)) {
return null;
}
if (hex.length <= 4) hex = [...hex].map((c) => c + c).join("");
const n = (i: number) => Number.parseInt(hex.slice(i, i + 2), 16);
return { r: n(0), g: n(2), b: n(4), a: hex.length === 8 ? n(6) / 255 : 1 };
}
const byte = (n: number) => Math.round(n).toString(16).padStart(2, "0");
/** Lowercase `#rrggbb`, or `#rrggbbaa` when `withAlpha` and alpha < 1. */
export function rgbToHex({ r, g, b, a }: RGBA, withAlpha = false): string {
const base = `#${byte(r)}${byte(g)}${byte(b)}`;
return withAlpha && a < 1 ? `${base}${byte(a * 255)}` : base;
}
export const hsvaToHex = (c: HSVA, withAlpha = false) =>
rgbToHex(hsvToRgb(c), withAlpha);
function hexToHsva(hex: string, fallbackHue = 0): HSVA | null {
const rgba = parseHex(hex);
return rgba ? rgbToHsv(rgba, fallbackHue) : null;
}
/* -------------------------------------------------------------------------- */
/* Helpers */
/* -------------------------------------------------------------------------- */
const DEFAULT_SWATCHES = [
"#ef4444",
"#f97316",
"#eab308",
"#22c55e",
"#06b6d4",
"#3b82f6",
"#6366f1",
"#a855f7",
"#ec4899",
"#18181b",
];
const HUE_GRADIENT =
"linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%)";
const CHECKER = {
backgroundImage: "repeating-conic-gradient(#d4d4d8 0% 25%, #ffffff 0% 50%)",
backgroundSize: "8px 8px",
} as const;
const SHAKE = { x: [0, -5, 5, -3, 3, 0] };
// A click that wanders less than this still counts as a click, so the
// jump-spring is not cut short by hand jitter.
const DRAG_SLOP = 3;
function useElementWidth(ref: RefObject<HTMLElement | null>) {
const [size, setSize] = useState({ width: 0, height: 0 });
useEffect(() => {
const el = ref.current;
if (!el) return;
const read = () =>
setSize({ width: el.clientWidth, height: el.clientHeight });
read();
const ro = new ResizeObserver(read);
ro.observe(el);
return () => ro.disconnect();
}, [ref]);
return size;
}
/**
* Pixel position for a thumb. Pointer-down and keyboard jumps spring there
* (SPRING_MOUSE); a drag follows the pointer 1:1.
*/
function useThumb(
target: number,
springNext: RefObject<boolean>,
reduce: boolean,
): MotionValue<number> {
const mv = useMotionValue(target);
useEffect(() => {
if (reduce || !springNext.current) {
mv.jump(target);
return;
}
const controls = animate(mv, target, { type: "spring", ...SPRING_MOUSE });
return () => controls.stop();
}, [target, mv, reduce, springNext]);
return mv;
}
/** Shared "spring the next move" flag, flipped off once a press becomes a drag. */
function useJumpFlag() {
const springNext = useRef(false);
const downAt = useRef<{ x: number; y: number } | null>(null);
return {
springNext,
down(event: PointerEvent) {
springNext.current = true;
downAt.current = { x: event.clientX, y: event.clientY };
},
move(event: PointerEvent) {
const d = downAt.current;
if (!d) return;
if (Math.hypot(event.clientX - d.x, event.clientY - d.y) > DRAG_SLOP) {
springNext.current = false;
downAt.current = null;
}
},
up() {
downAt.current = null;
},
key() {
springNext.current = true;
},
};
}
type JumpFlag = ReturnType<typeof useJumpFlag>;
/* -------------------------------------------------------------------------- */
/* ColorPicker */
/* -------------------------------------------------------------------------- */
export interface ColorPickerProps {
/** Controlled hex color (`#rrggbb`, or `#rrggbbaa` with `alpha`). */
value?: string;
/** Uncontrolled initial color. Default `#6366f1`. */
defaultValue?: string;
/** Receives lowercase hex; 8-digit only when `alpha` is on and alpha < 1. */
onChange?: (hex: string) => void;
/** Show the alpha slider and emit 8-digit hex when translucent. */
alpha?: boolean;
/** Preset colors. Pass `[]` to hide the row. */
swatches?: string[];
/** Show the hex text field. Default true. */
showInput?: boolean;
/** Show an eyedropper button where the browser supports the EyeDropper API. Default true. */
eyeDropper?: boolean;
disabled?: boolean;
className?: string;
}
export function ColorPicker({
value: valueProp,
defaultValue = "#6366f1",
onChange,
alpha = false,
swatches = DEFAULT_SWATCHES,
showInput = true,
eyeDropper = true,
disabled = false,
className,
}: ColorPickerProps) {
const uid = useId();
const reduce = useReducedMotion() ?? false;
const jump = useJumpFlag();
// HSV is the source of truth so hue survives trips through gray and black.
const [hsva, setHsva] = useState<HSVA>(
() =>
hexToHsva(valueProp ?? defaultValue) ?? { h: 239, s: 0.6, v: 0.95, a: 1 },
);
const hex = hsvaToHex(hsva, alpha);
const lastEmitted = useRef(hex);
// Pull in outside changes; ignore the parent echoing our own onChange.
useEffect(() => {
if (valueProp === undefined) return;
const parsed = parseHex(valueProp);
if (!parsed) return;
const normalized = rgbToHex(parsed, alpha);
if (normalized === lastEmitted.current) return;
lastEmitted.current = normalized;
setHsva((prev) => rgbToHsv(parsed, prev.h));
}, [valueProp, alpha]);
const update = (next: HSVA) => {
setHsva(next);
const out = hsvaToHex(next, alpha);
if (out === lastEmitted.current) return;
lastEmitted.current = out;
onChange?.(out);
};
const rgb = hsvToRgb(hsva);
const solid = `rgb(${rgb.r} ${rgb.g} ${rgb.b})`;
return (
<div
className={cn(
"flex w-64 flex-col gap-3",
disabled && "pointer-events-none opacity-60",
className,
)}
>
<SaturationArea
hsva={hsva}
solid={solid}
jump={jump}
reduce={reduce}
disabled={disabled}
onChange={update}
/>
<div className="flex items-center gap-3">
<div
className="relative size-9 shrink-0 overflow-hidden rounded-full ring-1 ring-border"
style={CHECKER}
>
<span
className="absolute inset-0 transition-colors duration-150"
style={{ backgroundColor: `rgb(${rgb.r} ${rgb.g} ${rgb.b} / ${hsva.a})` }}
/>
</div>
<div className="flex flex-1 flex-col gap-2.5">
<ChannelSlider
label="Hue"
value={Math.round(hsva.h)}
max={360}
valueText={(v) => `Hue ${v}°`}
background={HUE_GRADIENT}
thumbColor={`hsl(${hsva.h} 100% 50%)`}
jump={jump}
reduce={reduce}
disabled={disabled}
onChange={(h) => update({ ...hsva, h })}
/>
{alpha ? (
<ChannelSlider
label="Alpha"
value={Math.round(hsva.a * 100)}
max={100}
valueText={(v) => `Opacity ${v}%`}
background={`linear-gradient(to right, transparent, ${solid})`}
checker
thumbColor={`rgb(${rgb.r} ${rgb.g} ${rgb.b} / ${hsva.a})`}
jump={jump}
reduce={reduce}
disabled={disabled}
onChange={(a) => update({ ...hsva, a: a / 100 })}
/>
) : null}
</div>
</div>
{showInput ? (
<HexField
id={`${uid}-hex`}
hex={hex}
alpha={alpha}
disabled={disabled}
reduce={reduce}
eyeDropper={eyeDropper}
onCommit={(rgba) => {
jump.key();
update(rgbToHsv(alpha ? rgba : { ...rgba, a: 1 }, hsva.h));
}}
/>
) : null}
{swatches.length ? (
<fieldset className="m-0 flex min-w-0 flex-wrap gap-2 border-0 p-0">
<legend className="sr-only">Preset colors</legend>
{swatches.map((swatch) => {
const parsed = parseHex(swatch);
if (!parsed) return null;
const swatchHex = rgbToHex(parsed, alpha);
const selected =
swatchHex === hex ||
(parsed.a === 1 && rgbToHex(parsed) === hsvaToHex(hsva));
return (
<motion.button
key={swatch}
type="button"
disabled={disabled}
aria-label={swatchHex}
aria-pressed={selected}
whileTap={reduce ? undefined : { scale: 0.88 }}
onClick={() => {
jump.key();
update(
rgbToHsv(
alpha ? parsed : { ...parsed, a: 1 },
hsva.h,
),
);
}}
className="relative size-6 rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<span
className="absolute inset-0 overflow-hidden rounded-full ring-1 ring-inset ring-black/10"
style={CHECKER}
>
<span
className="absolute inset-0"
style={{ backgroundColor: swatch }}
/>
</span>
{selected ? (
<motion.span
layoutId={`${uid}-swatch-ring`}
transition={reduce ? { duration: 0 } : SPRING_LAYOUT}
className="pointer-events-none absolute -inset-1 rounded-full border-2 border-foreground"
/>
) : null}
</motion.button>
);
})}
</fieldset>
) : null}
</div>
);
}
/* -------------------------------------------------------------------------- */
/* Saturation area */
/* -------------------------------------------------------------------------- */
function SaturationArea({
hsva,
solid,
jump,
reduce,
disabled,
onChange,
}: {
hsva: HSVA;
solid: string;
jump: JumpFlag;
reduce: boolean;
disabled: boolean;
onChange: (next: HSVA) => void;
}) {
const areaRef = useRef<HTMLDivElement>(null);
const thumbRef = useRef<HTMLDivElement>(null);
const dragging = useRef(false);
const { width, height } = useElementWidth(areaRef);
const x = useThumb(hsva.s * width, jump.springNext, reduce);
const y = useThumb((1 - hsva.v) * height, jump.springNext, reduce);
const fromPointer = (clientX: number, clientY: number) => {
const rect = areaRef.current?.getBoundingClientRect();
if (!rect || rect.width === 0 || rect.height === 0) return;
onChange({
...hsva,
s: clamp((clientX - rect.left) / rect.width, 0, 1),
v: 1 - clamp((clientY - rect.top) / rect.height, 0, 1),
});
};
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
const big = e.shiftKey ? 0.1 : 0.01;
const moves: Record<string, [number, number]> = {
ArrowLeft: [-big, 0],
ArrowRight: [big, 0],
ArrowUp: [0, big],
ArrowDown: [0, -big],
PageUp: [0, 0.1],
PageDown: [0, -0.1],
Home: [-1, 0],
End: [1, 0],
};
const move = moves[e.key];
if (!move) return;
e.preventDefault();
jump.key();
onChange({
...hsva,
s: clamp(hsva.s + move[0], 0, 1),
v: clamp(hsva.v + move[1], 0, 1),
});
};
const s = Math.round(hsva.s * 100);
const v = Math.round(hsva.v * 100);
return (
// Pointer surface only — the focusable slider thumb inside carries role,
// value and keyboard.
<div
ref={areaRef}
onPointerDown={(e) => {
if (disabled || e.button !== 0) return;
dragging.current = true;
jump.down(e);
capturePointer(e.currentTarget, e.pointerId);
thumbRef.current?.focus({ preventScroll: true });
fromPointer(e.clientX, e.clientY);
}}
onPointerMove={(e) => {
if (!dragging.current) return;
jump.move(e);
fromPointer(e.clientX, e.clientY);
}}
onPointerUp={(e) => {
dragging.current = false;
jump.up();
releasePointer(e.currentTarget, e.pointerId);
}}
onPointerCancel={() => {
dragging.current = false;
jump.up();
}}
className={cn(
"relative h-40 w-full cursor-crosshair touch-none rounded-xl",
TOUCH_GESTURE_CLASS,
)}
style={{ backgroundColor: `hsl(${hsva.h} 100% 50%)` }}
>
<div className="pointer-events-none absolute inset-0 rounded-xl bg-linear-to-r from-white to-transparent" />
<div className="pointer-events-none absolute inset-0 rounded-xl bg-linear-to-t from-black to-transparent" />
<div className="pointer-events-none absolute inset-0 rounded-xl ring-1 ring-inset ring-black/10" />
<motion.div
ref={thumbRef}
role="slider"
tabIndex={disabled ? -1 : 0}
aria-label="Saturation and brightness"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={s}
aria-valuetext={`Saturation ${s}%, brightness ${v}%`}
aria-disabled={disabled || undefined}
onKeyDown={onKeyDown}
style={{ x, y, backgroundColor: solid, opacity: width ? 1 : 0 }}
className="absolute left-0 top-0 -ml-2 -mt-2 size-4 rounded-full border-2 border-white shadow-md outline-none ring-1 ring-black/25 transition-[box-shadow] focus-visible:ring-4 focus-visible:ring-white/60"
/>
</div>
);
}
/* -------------------------------------------------------------------------- */
/* Channel slider */
/* -------------------------------------------------------------------------- */
function ChannelSlider({
label,
value,
max,
valueText,
background,
checker = false,
thumbColor,
jump,
reduce,
disabled,
onChange,
}: {
label: string;
value: number;
max: number;
valueText: (v: number) => string;
background: string;
checker?: boolean;
thumbColor: string;
jump: JumpFlag;
reduce: boolean;
disabled: boolean;
onChange: (v: number) => void;
}) {
const { trackProps, sliderProps, percent } = useSlider({
value,
onValueChange: onChange,
min: 0,
max,
step: 1,
disabled,
"aria-label": label,
formatValueText: valueText,
});
const { width } = useElementWidth(trackProps.ref);
const x = useThumb((percent / 100) * width, jump.springNext, reduce);
return (
<div
{...trackProps}
onPointerDown={(e) => {
if (e.button !== 0) return;
jump.down(e);
trackProps.onPointerDown(e);
}}
onPointerMove={(e) => {
jump.move(e);
trackProps.onPointerMove(e);
}}
onPointerUp={(e) => {
jump.up();
trackProps.onPointerUp(e);
}}
className={cn(
"relative h-3 w-full cursor-pointer touch-none rounded-full",
TOUCH_GESTURE_CLASS,
)}
style={checker ? CHECKER : undefined}
>
<div
className="pointer-events-none absolute inset-0 rounded-full ring-1 ring-inset ring-black/10"
style={{ backgroundImage: background }}
/>
<motion.div
{...sliderProps}
onKeyDown={(e) => {
jump.key();
sliderProps.onKeyDown(e);
}}
style={{ x, opacity: width ? 1 : 0 }}
className="absolute left-0 top-1/2 -ml-2.5 -mt-2.5 size-5 overflow-hidden rounded-full border-2 border-white shadow-md outline-none ring-1 ring-black/25 focus-visible:ring-4 focus-visible:ring-white/60"
>
<span className="absolute inset-0" style={checker ? CHECKER : undefined} />
<span
className="absolute inset-0"
style={{ backgroundColor: thumbColor }}
/>
</motion.div>
</div>
);
}
/* -------------------------------------------------------------------------- */
/* Hex field */
/* -------------------------------------------------------------------------- */
interface EyeDropperCtor {
new (): { open: () => Promise<{ sRGBHex: string }> };
}
function HexField({
id,
hex,
alpha,
disabled,
reduce,
eyeDropper,
onCommit,
}: {
id: string;
hex: string;
alpha: boolean;
disabled: boolean;
reduce: boolean;
eyeDropper: boolean;
onCommit: (rgba: RGBA) => void;
}) {
const fieldRef = useRef<HTMLDivElement>(null);
const [draft, setDraft] = useState<string | null>(null);
// Feature-detected after mount so server and client render the same markup.
const [dropper, setDropper] = useState<EyeDropperCtor | null>(null);
useEffect(() => {
if (!eyeDropper) return;
const ctor = (window as unknown as { EyeDropper?: EyeDropperCtor })
.EyeDropper;
if (ctor) setDropper(() => ctor);
}, [eyeDropper]);
const shown = draft ?? hex.slice(1).toUpperCase();
const invalid = draft !== null && parseHex(draft) === null;
const shake = () => {
if (!reduce && fieldRef.current) {
animate(fieldRef.current, SHAKE, { duration: 0.35, ease: EASE_OUT });
}
};
const settle = () => {
if (draft === null) return;
if (invalid) shake();
setDraft(null);
};
return (
<div className="flex items-center gap-2">
<motion.div
ref={fieldRef}
className={cn(
"flex h-9 flex-1 items-center rounded-lg border border-border bg-background pl-2.5 text-sm transition-colors duration-150 focus-within:border-foreground/40 focus-within:ring-2 focus-within:ring-ring/40",
invalid &&
"border-destructive focus-within:border-destructive focus-within:ring-destructive/25",
)}
>
<label htmlFor={id} className="select-none text-muted-foreground">
<span aria-hidden>#</span>
<span className="sr-only">Hex color</span>
</label>
<input
id={id}
type="text"
value={shown}
disabled={disabled}
maxLength={alpha ? 9 : 7}
spellCheck={false}
autoComplete="off"
aria-invalid={invalid || undefined}
onChange={(e) => {
const next = e.target.value.replace(/^#/, "");
setDraft(next);
// Only complete 6/8-digit codes apply live; short forms wait for
// commit so typing "abc" on the way to "abcdef" doesn't flash.
const ok = next.length === 6 || (alpha && next.length === 8);
const parsed = ok ? parseHex(next) : null;
if (parsed) onCommit(parsed);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
const parsed = draft === null ? null : parseHex(draft);
if (parsed) onCommit(parsed);
settle();
} else if (e.key === "Escape") {
setDraft(null);
}
}}
onBlur={() => {
const parsed = draft === null ? null : parseHex(draft);
if (parsed) onCommit(parsed);
settle();
}}
className="h-full w-full min-w-0 bg-transparent pr-2.5 pl-0.5 font-mono uppercase tabular-nums text-foreground outline-none"
/>
</motion.div>
{dropper ? (
<motion.button
type="button"
aria-label="Pick a color from the screen"
disabled={disabled}
whileTap={reduce ? undefined : { scale: 0.9 }}
onClick={async () => {
try {
const result = await new dropper().open();
const parsed = parseHex(result.sRGBHex);
if (parsed) onCommit(parsed);
} catch {
// User dismissed the eyedropper.
}
}}
className="grid size-9 shrink-0 place-items-center rounded-lg border border-border text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<Pipette className="size-4" aria-hidden />
</motion.button>
) : null}
</div>
);
}
/* -------------------------------------------------------------------------- */
/* ColorPickerPopover */
/* -------------------------------------------------------------------------- */
export interface ColorPickerPopoverProps
extends Omit<ColorPickerProps, "className"> {
/** Controlled open state. */
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
/** Visible label next to the swatch in the trigger. */
label?: string;
side?: "top" | "bottom";
align?: "start" | "end";
className?: string;
/** Class for the picker inside the panel. */
pickerClassName?: string;
}
/** A swatch trigger that morphs a `ColorPicker` open in a `MorphPopover`. */
export function ColorPickerPopover({
open,
defaultOpen,
onOpenChange,
label,
side = "bottom",
align = "start",
className,
pickerClassName,
value: valueProp,
defaultValue = "#6366f1",
onChange,
disabled,
alpha,
...pickerProps
}: ColorPickerPopoverProps) {
const [internal, setInternal] = useState(defaultValue);
const value = valueProp ?? internal;
const swatch = useMemo(() => parseHex(value), [value]);
return (
<MorphPopover
open={open}
defaultOpen={defaultOpen}
onOpenChange={onOpenChange}
>
<MorphPopoverTrigger>
<button
type="button"
disabled={disabled}
aria-label={`${label ?? "Color"}: ${value}`}
className={cn(
"inline-flex h-10 items-center gap-2.5 rounded-xl border border-border bg-background pr-3 pl-1.5 text-sm outline-none transition-colors hover:border-border-strong focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
className,
)}
>
<span
className="relative size-7 overflow-hidden rounded-lg ring-1 ring-inset ring-black/10"
style={CHECKER}
>
<span
className="absolute inset-0 transition-colors duration-150"
style={{
backgroundColor: swatch
? `rgb(${swatch.r} ${swatch.g} ${swatch.b} / ${swatch.a})`
: value,
}}
/>
</span>
{label ? <span className="text-foreground">{label}</span> : null}
<span className="font-mono uppercase tabular-nums text-muted-foreground">
{value}
</span>
</button>
</MorphPopoverTrigger>
<MorphPopoverContent side={side} align={align} className="p-3">
<ColorPicker
{...pickerProps}
alpha={alpha}
disabled={disabled}
value={value}
onChange={(hex) => {
if (valueProp === undefined) setInternal(hex);
onChange?.(hex);
}}
className={pickerClassName}
/>
</MorphPopoverContent>
</MorphPopover>
);
}
"use client";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
cloneElement,
createContext,
isValidElement,
type ReactElement,
type ReactNode,
type Ref,
useCallback,
useContext,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import { usePopoverPortalPosition } from "@/components/motion/popover-position";
import { EASE_OUT, SPRING_PANEL } from "@/lib/ease";
import { cn } from "@/lib/utils";
type Side = "top" | "bottom";
type Align = "start" | "end";
type MorphContextValue = {
open: boolean;
setOpen: (open: boolean) => void;
toggle: () => void;
triggerId: string;
contentId: string;
/** The element the panel measures against — see `registerTrigger`. */
triggerRef: React.MutableRefObject<HTMLElement | null>;
registerTrigger: (node: HTMLElement | null) => void;
contentRef: React.MutableRefObject<HTMLDivElement | null>;
};
const MorphContext = createContext<MorphContextValue | null>(null);
function useMorphContext(component: string) {
const ctx = useContext(MorphContext);
if (!ctx) throw new Error(`${component} must be used within <MorphPopover>`);
return ctx;
}
export interface MorphPopoverProps {
children: ReactNode;
/** Controlled open state. */
open?: boolean;
/** Uncontrolled initial open state. */
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
className?: string;
}
/**
* A popover whose panel morphs open from the trigger corner: it's laid out at
* full size but clipped to the corner nearest the trigger, then unclips as one
* piece. Closes on outside pointer / Escape. Controlled or uncontrolled.
*/
export function MorphPopover({
children,
open: controlledOpen,
defaultOpen = false,
onOpenChange,
className,
}: MorphPopoverProps) {
const baseId = useId();
const [root, setRoot] = useState<HTMLDivElement | null>(null);
const [trigger, setTrigger] = useState<HTMLElement | null>(null);
const contentRef = useRef<HTMLDivElement | null>(null);
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const controlled = controlledOpen !== undefined;
const open = controlled ? controlledOpen : internalOpen;
const setOpen = useCallback(
(next: boolean) => {
if (!controlled) setInternalOpen(next);
onOpenChange?.(next);
},
[controlled, onOpenChange],
);
const toggle = useCallback(() => setOpen(!open), [setOpen, open]);
// A trigger normally registers itself through MorphPopoverTrigger. It can't
// when something else already clones the element — a Tooltip wrapping the
// button, say — and an unregistered trigger leaves the panel with nothing to
// measure against, so it renders permanently invisible. The root boxes the
// trigger exactly (the content portals out of it), so it stands in until a
// real trigger registers, and stands in again if that one unmounts. Both are
// state, so a trigger arriving while the panel is open re-anchors it.
const anchorRef = useMemo<React.MutableRefObject<HTMLElement | null>>(
() => ({ current: trigger ?? root }),
[root, trigger],
);
// The panel is a `role="dialog"` and goes inert the moment it closes, so
// focus cannot be left sitting inside it: a dismissal hands it back to the
// trigger, the way the ARIA dialog pattern asks. A pointer dismissal takes
// the focus onward itself when it lands on something focusable — this only
// catches the case where it would otherwise be stranded. When no trigger has
// registered, the root anchor stands in only if it can actually hold focus;
// there is nowhere better than where the keyboard already is, so leave it.
const close = useCallback(() => {
setOpen(false);
const focused = document.activeElement;
const inPanel =
focused instanceof HTMLElement && contentRef.current?.contains(focused);
if (!inPanel) return;
const restore = trigger ?? (root && root.tabIndex >= 0 ? root : null);
restore?.focus();
}, [root, setOpen, trigger]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => e.key === "Escape" && close();
const onPointer = (e: PointerEvent) => {
const target = e.target as Node;
if (
root &&
!root.contains(target) &&
!contentRef.current?.contains(target)
)
close();
};
window.addEventListener("keydown", onKey);
window.addEventListener("pointerdown", onPointer);
return () => {
window.removeEventListener("keydown", onKey);
window.removeEventListener("pointerdown", onPointer);
};
}, [open, root, close]);
const ctx = useMemo<MorphContextValue>(
() => ({
open,
setOpen,
toggle,
triggerId: `${baseId}-trigger`,
contentId: `${baseId}-content`,
triggerRef: anchorRef,
registerTrigger: setTrigger,
contentRef,
}),
[open, setOpen, toggle, baseId, anchorRef],
);
return (
<MorphContext.Provider value={ctx}>
<div ref={setRoot} className={cn("relative inline-flex", className)}>
{children}
</div>
</MorphContext.Provider>
);
}
export interface MorphPopoverTriggerProps {
children: ReactElement;
}
function mergeRefs<T>(...refs: Array<Ref<T> | undefined>) {
return (node: T | null) => {
for (const ref of refs) {
if (typeof ref === "function") ref(node);
else if (ref && typeof ref === "object")
(ref as React.MutableRefObject<T | null>).current = node;
}
};
}
/** Wraps a single element, toggling the popover on click. */
export function MorphPopoverTrigger({ children }: MorphPopoverTriggerProps) {
const ctx = useMorphContext("MorphPopoverTrigger");
if (!isValidElement(children)) return children;
const child = children as ReactElement<Record<string, unknown>>;
const childOnClick = child.props.onClick as
| ((e: unknown) => void)
| undefined;
const childRef = (child.props as { ref?: Ref<HTMLElement> }).ref;
return cloneElement(child, {
id: ctx.triggerId,
ref: mergeRefs(childRef, ctx.registerTrigger),
onClick: (e: unknown) => {
childOnClick?.(e);
ctx.toggle();
},
"aria-haspopup": "dialog",
"aria-expanded": ctx.open,
"aria-controls": ctx.open ? ctx.contentId : undefined,
});
}
const originFor = (side: Side, align: Align) =>
`${side === "bottom" ? "top" : "bottom"} ${align === "end" ? "right" : "left"}`;
// A clip that hides everything but the corner nearest the trigger, so the
// panel appears to grow out of it. inset(top right bottom left).
function clipHidden(side: Side, align: Align, radius: number) {
const top = side === "bottom" ? "0%" : "92%";
const bottom = side === "bottom" ? "92%" : "0%";
const right = align === "end" ? "0%" : "92%";
const left = align === "end" ? "92%" : "0%";
return `inset(${top} ${right} ${bottom} ${left} round ${radius}px)`;
}
const clipShown = (radius: number) => `inset(0% 0% 0% 0% round ${radius}px)`;
// Preserve the original spring character on the wrapper, but tween the complex
// clip-path so it cannot snap when the spring resolves its final distance.
const MORPH_CLIP_TRANSITION = { duration: 0.32, ease: EASE_OUT } as const;
export interface MorphPopoverContentProps {
children: ReactNode;
side?: Side;
align?: Align;
/** Gap between trigger and panel, in px. Default 8. */
sideOffset?: number;
/** Panel corner radius, in px. Default 16. */
radius?: number;
className?: string;
}
export function MorphPopoverContent({
children,
side = "bottom",
align = "end",
sideOffset = 8,
radius = 16,
className,
}: MorphPopoverContentProps) {
const ctx = useMorphContext("MorphPopoverContent");
const reduce = useReducedMotion() ?? false;
const [portalReady, setPortalReady] = useState(false);
const layout = usePopoverPortalPosition(
ctx.triggerRef,
ctx.contentRef,
portalReady && ctx.open,
);
useEffect(() => setPortalReady(true), []);
const left = layout
? align === "end"
? layout.trigger.left + layout.trigger.width - layout.content.width
: layout.trigger.left
: 0;
const top = layout
? side === "bottom"
? layout.trigger.top + layout.trigger.height + sideOffset
: layout.trigger.top - layout.content.height - sideOffset
: 0;
// Both directions travel between the exact same hidden/show states. Exit
// targets "hidden" directly instead of introducing separate choreography.
const wrap = reduce
? undefined
: {
hidden: { opacity: 0, scale: 0.96, transition: SPRING_PANEL },
show: { opacity: 1, scale: 1, transition: SPRING_PANEL },
};
const clip = reduce
? undefined
: {
hidden: {
clipPath: clipHidden(side, align, radius),
transition: MORPH_CLIP_TRANSITION,
},
show: {
clipPath: clipShown(radius),
transition: MORPH_CLIP_TRANSITION,
},
};
// Keep the server and first client render identical, then mount the portal.
if (!portalReady) return null;
return createPortal(
<AnimatePresence>
{ctx.open ? (
<motion.div
data-morph-popover-portal=""
// Wrapper carries the shadow as a drop-shadow filter, which hugs the
// clipped shape below (box-shadow would just get clipped away).
variants={wrap}
initial={reduce ? { opacity: 0 } : "hidden"}
animate={reduce ? { opacity: 1 } : "show"}
exit={reduce ? { opacity: 0 } : "hidden"}
transition={reduce ? { duration: 0.12 } : undefined}
style={{
left,
top,
visibility: layout ? "visible" : "hidden",
transformOrigin: originFor(side, align),
}}
className="fixed z-[9999] [filter:drop-shadow(0_10px_18px_rgba(0,0,0,0.14))]"
>
<motion.div
ref={ctx.contentRef}
id={ctx.contentId}
role="dialog"
aria-labelledby={ctx.triggerId}
variants={clip}
style={{ borderRadius: radius }}
className={cn(
"overflow-hidden border border-border bg-background",
className,
)}
>
{children}
</motion.div>
</motion.div>
) : null}
</AnimatePresence>,
document.body,
);
}
"use client";
import {
type MutableRefObject,
useCallback,
useLayoutEffect,
useState,
} from "react";
export type PortalLayout = {
trigger: {
left: number;
top: number;
width: number;
height: number;
};
content: {
width: number;
height: number;
};
};
function sameLayout(a: PortalLayout | null, b: PortalLayout) {
return (
a?.trigger.left === b.trigger.left &&
a.trigger.top === b.trigger.top &&
a.trigger.width === b.trigger.width &&
a.trigger.height === b.trigger.height &&
a.content.width === b.content.width &&
a.content.height === b.content.height
);
}
/** Measures a trigger and portalled panel in viewport coordinates. */
export function usePopoverPortalPosition<
TriggerElement extends HTMLElement,
ContentElement extends HTMLElement,
>(
triggerRef: MutableRefObject<TriggerElement | null>,
contentRef: MutableRefObject<ContentElement | null>,
active: boolean,
) {
const [layout, setLayout] = useState<PortalLayout | null>(null);
const update = useCallback(() => {
const trigger = triggerRef.current;
const content = contentRef.current;
if (!trigger || !content) return;
const rect = trigger.getBoundingClientRect();
const next: PortalLayout = {
trigger: {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
},
content: {
width: content.offsetWidth,
height: content.offsetHeight,
},
};
setLayout((current) => (sameLayout(current, next) ? current : next));
}, [contentRef, triggerRef]);
useLayoutEffect(() => {
update();
if (!active) return;
const trigger = triggerRef.current;
const content = contentRef.current;
const observer = new ResizeObserver(update);
if (trigger) observer.observe(trigger);
if (content) observer.observe(content);
window.addEventListener("scroll", update, true);
window.addEventListener("resize", update);
return () => {
observer.disconnect();
window.removeEventListener("scroll", update, true);
window.removeEventListener("resize", update);
};
}, [active, contentRef, triggerRef, update]);
return layout;
}
@karaui not found? Add it to components.json
{
"registries": {
"@karaui": "https://karaui.kamisama-v0.my.id/r/{name}.json"
}
}@karaui is in shadcn's public registry directory, so the latest CLI needs no setup. On an older CLI, merge this into the existing file once and every @karaui/… install resolves.
API Reference
hsvToRgb
hnumber—snumber—vnumber—anumber—ColorPicker
value?stringControlled hex color (`#rrggbb`, or `#rrggbbaa` with `alpha`).
—defaultValue?stringUncontrolled initial color. Default `#6366f1`.
#6366f1onChange?((hex: string) => void)Receives lowercase hex; 8-digit only when `alpha` is on and alpha < 1.
—alpha?booleanShow the alpha slider and emit 8-digit hex when translucent.
falseswatches?string[]Preset colors. Pass `[]` to hide the row.
[
"#ef4444",
"#f97316",
"#eab308",
"#22c55e",
"#06b6d4",
"#3b82f6",
"#6366f1",
"#a855f7",
"#ec4899",
"#18181b",
]showInput?booleanShow the hex text field. Default true.
trueeyeDropper?booleanShow an eyedropper button where the browser supports the EyeDropper API. Default true.
truedisabled?booleanfalseclassName?string—ColorPickerPopover
open?booleanControlled open state.
—defaultOpen?boolean—onOpenChange?((open: boolean) => void)—label?stringVisible label next to the swatch in the trigger.
—side?"bottom" | "top"bottomalign?"end" | "start"startclassName?string—pickerClassName?stringClass for the picker inside the panel.
—disabled?boolean—value?stringControlled hex color (`#rrggbb`, or `#rrggbbaa` with `alpha`).
—defaultValue?stringUncontrolled initial color. Default `#6366f1`.
#6366f1onChange?((hex: string) => void)Receives lowercase hex; 8-digit only when `alpha` is on and alpha < 1.
—alpha?booleanShow the alpha slider and emit 8-digit hex when translucent.
—swatches?string[]Preset colors. Pass `[]` to hide the row.
—showInput?booleanShow the hex text field. Default true.
—eyeDropper?booleanShow an eyedropper button where the browser supports the EyeDropper API. Default true.
—Updated