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.
Combobox
Searchable combobox with a morphing portal, grouped filtering, keyboard navigation, and controlled or uncontrolled state.
Radio Group
Single-select choice control with a gliding layoutId indicator dot and spring press feedback.
Updated