Animated Color Selector
Composable color swatches with a spring-gliding selection ring, press feedback, native radio keyboard navigation, and controlled or uncontrolled state.
Preview
"use client";
import {
ColorSelector,
ColorSelectorItem,
ColorSelectorLabel,
ColorSelectorList,
} from "@/components/motion/color-selector";
export function ColorSelectorPreview() {
return (
<ColorSelector defaultValue="blue" name="accent">
<ColorSelectorLabel>Accent</ColorSelectorLabel>
<ColorSelectorList>
<ColorSelectorItem value="blue" color="#3478f6" label="Blue" />
<ColorSelectorItem value="purple" color="#9270e8" label="Purple" />
<ColorSelectorItem value="pink" color="#e66aa4" label="Pink" />
<ColorSelectorItem value="red" color="#e55656" label="Red" />
<ColorSelectorItem value="orange" color="#ed9141" label="Orange" />
<ColorSelectorItem value="amber" color="#e5b63c" label="Amber" />
<ColorSelectorItem value="green" color="#65a65a" label="Green" />
<ColorSelectorItem value="teal" color="#169d83" label="Teal" />
</ColorSelectorList>
</ColorSelector>
);
}
"use client";
// beui.dev/components/motion/color-selector
import { LayoutGroup, motion, useReducedMotion } from "motion/react";
import { createContext, useContext, useId, useState, type ComponentPropsWithRef } from "react";
import { SPRING_LAYOUT, SPRING_PRESS } from "@/lib/ease";
import { cn } from "@/lib/utils";
type ColorSelectorContextValue = {
value: string;
select: (value: string) => void;
name: string;
disabled: boolean;
required: boolean;
form?: string;
};
const ColorSelectorContext = createContext<ColorSelectorContextValue | null>(null);
export interface ColorSelectorProps extends Omit<ComponentPropsWithRef<"fieldset">, "onChange" | "defaultValue"> {
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
/** The radio group name used in form submission. Generated when omitted. */
name?: string;
required?: boolean;
}
/** A single color choice. Give it a ColorSelectorLabel or an aria-label. */
export function ColorSelector({
value,
defaultValue = "",
onValueChange,
name,
disabled = false,
required = false,
form,
className,
children,
...props
}: ColorSelectorProps) {
const id = useId();
const [internal, setInternal] = useState(defaultValue);
const current = value ?? internal;
return (
<ColorSelectorContext.Provider value={{
value: current,
select: (next) => {
if (next === current) return;
if (value === undefined) setInternal(next);
onValueChange?.(next);
},
name: name ?? id,
disabled,
required,
form,
}}>
<fieldset {...props} disabled={disabled} form={form} className={cn("min-w-0 border-0 p-0", className)}>
<LayoutGroup id={id}>{children}</LayoutGroup>
</fieldset>
</ColorSelectorContext.Provider>
);
}
export type ColorSelectorLabelProps = ComponentPropsWithRef<"legend">;
export function ColorSelectorLabel({ className, ...props }: ColorSelectorLabelProps) {
return <legend {...props} className={cn("mb-3 p-0 text-sm font-medium text-muted-foreground", className)} />;
}
export type ColorSelectorListProps = ComponentPropsWithRef<"div">;
export function ColorSelectorList({ className, ...props }: ColorSelectorListProps) {
return <div {...props} className={cn("flex flex-wrap items-center gap-3 p-1", className)} />;
}
export interface ColorSelectorItemProps extends Omit<ComponentPropsWithRef<"input">, "type" | "value" | "defaultValue" | "checked" | "defaultChecked" | "name" | "children" | "size"> {
value: string;
/** Any CSS color, including a custom property. */
color: string;
/** Accessible color name; selection never relies on color alone. */
label: string;
/** Applied to the visible swatch surface. */
className?: string;
}
export function ColorSelectorItem({ value, color, label, disabled = false, className, style, onChange, ...props }: ColorSelectorItemProps) {
const context = useContext(ColorSelectorContext);
const reduce = useReducedMotion();
if (!context) throw new Error("ColorSelectorItem must be used within ColorSelector");
const selected = context.value === value;
const unavailable = disabled || context.disabled;
return (
<motion.label
tabIndex={-1}
whileTap={reduce || unavailable ? undefined : { scale: 0.94 }}
transition={SPRING_PRESS}
className={cn("relative inline-flex shrink-0 rounded-full", unavailable ? "cursor-not-allowed opacity-40" : "cursor-pointer")}>
<input
{...props}
type="radio"
name={context.name}
value={value}
checked={selected}
disabled={unavailable}
required={context.required}
form={context.form}
aria-label={label}
onChange={(event) => {
onChange?.(event);
if (!event.defaultPrevented) context.select(value);
}}
className="peer sr-only"
/>
<span
aria-hidden="true"
style={style}
className={cn(
"relative flex size-11 items-center justify-center rounded-full border border-foreground/10 bg-muted/60",
"peer-focus-visible:outline-2 peer-focus-visible:outline-offset-4 peer-focus-visible:outline-foreground",
className,
)}
>
{selected && (
<motion.span
layoutId={reduce ? undefined : "color-selection"}
initial={false}
transition={SPRING_LAYOUT}
className="pointer-events-none absolute -inset-1 rounded-full border-2"
style={{ borderColor: `color-mix(in srgb, ${color} 65%, transparent)` }}
/>
)}
<span className="size-5 rounded-full border border-black/5" style={{ backgroundColor: color }} />
</span>
</motion.label>
);
}
Install
Add it with the shadcn CLI, or copy the source manually.
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx 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;
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";
// beui.dev/components/motion/color-selector
import { LayoutGroup, motion, useReducedMotion } from "motion/react";
import { createContext, useContext, useId, useState, type ComponentPropsWithRef } from "react";
import { SPRING_LAYOUT, SPRING_PRESS } from "@/lib/ease";
import { cn } from "@/lib/utils";
type ColorSelectorContextValue = {
value: string;
select: (value: string) => void;
name: string;
disabled: boolean;
required: boolean;
form?: string;
};
const ColorSelectorContext = createContext<ColorSelectorContextValue | null>(null);
export interface ColorSelectorProps extends Omit<ComponentPropsWithRef<"fieldset">, "onChange" | "defaultValue"> {
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
/** The radio group name used in form submission. Generated when omitted. */
name?: string;
required?: boolean;
}
/** A single color choice. Give it a ColorSelectorLabel or an aria-label. */
export function ColorSelector({
value,
defaultValue = "",
onValueChange,
name,
disabled = false,
required = false,
form,
className,
children,
...props
}: ColorSelectorProps) {
const id = useId();
const [internal, setInternal] = useState(defaultValue);
const current = value ?? internal;
return (
<ColorSelectorContext.Provider value={{
value: current,
select: (next) => {
if (next === current) return;
if (value === undefined) setInternal(next);
onValueChange?.(next);
},
name: name ?? id,
disabled,
required,
form,
}}>
<fieldset {...props} disabled={disabled} form={form} className={cn("min-w-0 border-0 p-0", className)}>
<LayoutGroup id={id}>{children}</LayoutGroup>
</fieldset>
</ColorSelectorContext.Provider>
);
}
export type ColorSelectorLabelProps = ComponentPropsWithRef<"legend">;
export function ColorSelectorLabel({ className, ...props }: ColorSelectorLabelProps) {
return <legend {...props} className={cn("mb-3 p-0 text-sm font-medium text-muted-foreground", className)} />;
}
export type ColorSelectorListProps = ComponentPropsWithRef<"div">;
export function ColorSelectorList({ className, ...props }: ColorSelectorListProps) {
return <div {...props} className={cn("flex flex-wrap items-center gap-3 p-1", className)} />;
}
export interface ColorSelectorItemProps extends Omit<ComponentPropsWithRef<"input">, "type" | "value" | "defaultValue" | "checked" | "defaultChecked" | "name" | "children" | "size"> {
value: string;
/** Any CSS color, including a custom property. */
color: string;
/** Accessible color name; selection never relies on color alone. */
label: string;
/** Applied to the visible swatch surface. */
className?: string;
}
export function ColorSelectorItem({ value, color, label, disabled = false, className, style, onChange, ...props }: ColorSelectorItemProps) {
const context = useContext(ColorSelectorContext);
const reduce = useReducedMotion();
if (!context) throw new Error("ColorSelectorItem must be used within ColorSelector");
const selected = context.value === value;
const unavailable = disabled || context.disabled;
return (
<motion.label
tabIndex={-1}
whileTap={reduce || unavailable ? undefined : { scale: 0.94 }}
transition={SPRING_PRESS}
className={cn("relative inline-flex shrink-0 rounded-full", unavailable ? "cursor-not-allowed opacity-40" : "cursor-pointer")}>
<input
{...props}
type="radio"
name={context.name}
value={value}
checked={selected}
disabled={unavailable}
required={context.required}
form={context.form}
aria-label={label}
onChange={(event) => {
onChange?.(event);
if (!event.defaultPrevented) context.select(value);
}}
className="peer sr-only"
/>
<span
aria-hidden="true"
style={style}
className={cn(
"relative flex size-11 items-center justify-center rounded-full border border-foreground/10 bg-muted/60",
"peer-focus-visible:outline-2 peer-focus-visible:outline-offset-4 peer-focus-visible:outline-foreground",
className,
)}
>
{selected && (
<motion.span
layoutId={reduce ? undefined : "color-selection"}
initial={false}
transition={SPRING_LAYOUT}
className="pointer-events-none absolute -inset-1 rounded-full border-2"
style={{ borderColor: `color-mix(in srgb, ${color} 65%, transparent)` }}
/>
)}
<span className="size-5 rounded-full border border-black/5" style={{ backgroundColor: color }} />
</span>
</motion.label>
);
}
API Reference
ColorSelector
value?string—defaultValue?stringonValueChange?((value: string) => void)—name?stringThe radio group name used in form submission. Generated when omitted.
—required?booleanfalseclassName?string—ColorSelectorLabel
className?string—ColorSelectorList
className?string—ColorSelectorItem
valuestring—colorstringAny CSS color, including a custom property.
—labelstringAccessible color name; selection never relies on color alone.
—className?stringApplied to the visible swatch surface.
—Related components
Animated Context Menu
Composable context-menu primitives with a pointer-origin clip morph, a gliding active row, checkbox and radio choices, keyboard navigation, typeahead, and long-press support.
Sortable List
Composable sortable list with group, item, drag handle, content and undo parts, plus a shared state hook. Spring layout, keyboard reordering, accessible position announcements, and controlled or uncontrolled items.
Image Viewer
A composable React image viewer for galleries and attachment previews, with animated lightbox variants, custom thumbnails and controls, swipe and keyboard navigation, zoom and pan, accessible focus management, and controlled or uncontrolled selection.
Updated