"use client";
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { cva, type VariantProps } from "class-variance-authority";
import { LayoutGroup, motion } from "motion/react";
import * as React from "react";
import { cn } from "@/lib/utils";
const componentThemeClassName =
"[--ic-background:#ffffff] [--ic-foreground:#111111] [--ic-primary:#111111] [--ic-secondary:#646b75] [--ic-surface-border:#e9edf2] [--ic-border:#e3e7ec] [--ic-card:#ffffff] [--ic-card-foreground:#111111] [--ic-muted:#f5f7fa] [--ic-muted-foreground:#6d7480] [--ic-accent:#f3f5f8] [--color-accent:var(--ic-accent)] [--color-accent-foreground:var(--ic-accent-foreground)] [--ic-accent-foreground:#111111] [--ic-input:#e3e7ec] [--ic-ring:rgba(17,17,17,0.16)] [--ic-destructive:#dc2626] [--ic-paper:#fcfcfd] [--ic-popover-foreground:#111111] [--ic-brand:#0ea5e9] [--ic-brand-soft:#bae6fd] [--ic-shadow-soft:0_18px_38px_-24px_rgba(15,23,42,0.35)] [--ic-chart-1:oklch(0.52_0.19_254)] [--ic-chart-2:oklch(0.74_0.11_232)] [--ic-chart-3:oklch(0.42_0.16_262)] [--ic-chart-4:oklch(0.84_0.07_228)] [--ic-chart-5:oklch(0.62_0.14_240)] [--color-background:var(--ic-background)] [--color-foreground:var(--ic-foreground)] [--color-primary:var(--ic-primary)] [--color-secondary:var(--ic-secondary)] [--color-border:var(--ic-border)] [--color-card:var(--ic-card)] [--color-card-foreground:var(--ic-card-foreground)] [--color-muted:var(--ic-muted)] [--color-muted-foreground:var(--ic-muted-foreground)] [--color-accent:var(--ic-accent)] [--color-accent-foreground:var(--ic-accent-foreground)] [--color-input:var(--ic-input)] [--color-ring:var(--ic-ring)] [--color-destructive:var(--ic-destructive)] [--color-paper:var(--ic-paper)] [--color-popover-foreground:var(--ic-popover-foreground)] [--color-brand:var(--ic-brand)] [--color-brand-soft:var(--ic-brand-soft)] [--color-chart-1:var(--ic-chart-1)] [--color-chart-2:var(--ic-chart-2)] [--color-chart-3:var(--ic-chart-3)] [--color-chart-4:var(--ic-chart-4)] [--color-chart-5:var(--ic-chart-5)] dark:[--ic-background:#111111] dark:[--ic-foreground:#f6f3ec] dark:[--ic-primary:#f6f3ec] dark:[--ic-secondary:#cbc6bb] dark:[--ic-surface-border:#2a2a25] dark:[--ic-border:#2b2a25] dark:[--ic-card:#111111] dark:[--ic-card-foreground:#f6f3ec] dark:[--ic-muted:#171716] dark:[--ic-muted-foreground:#9a958a] dark:[--ic-accent:#1a1a18] [--color-accent:var(--ic-accent)] [--color-accent-foreground:var(--ic-accent-foreground)] dark:[--ic-accent-foreground:#f6f3ec] dark:[--ic-input:#2b2a25] dark:[--ic-ring:rgba(246,243,236,0.18)] dark:[--ic-destructive:#f87171] dark:[--ic-paper:#171716] dark:[--ic-popover-foreground:#f6f3ec] dark:[--ic-brand:#38bdf8] dark:[--ic-brand-soft:#0c4a6e] dark:[--ic-shadow-soft:0_20px_44px_-28px_rgba(0,0,0,0.6)] dark:[--ic-chart-1:oklch(0.68_0.17_250)] dark:[--ic-chart-2:oklch(0.82_0.09_225)] dark:[--ic-chart-3:oklch(0.58_0.15_260)] dark:[--ic-chart-4:oklch(0.75_0.12_235)] dark:[--ic-chart-5:oklch(0.88_0.06_220)]";
type ButtonGroupSize = "sm" | "md" | "lg";
type ButtonVariant = "default" | "destructive" | "ghost" | "outline";
type ButtonGroupContextValue = {
connected: boolean;
size: ButtonGroupSize;
};
const ButtonGroupContext = React.createContext<ButtonGroupContextValue | null>(
null
);
function useButtonGroupContext() {
return React.useContext(ButtonGroupContext);
}
function resolveButtonGroupSize(
size: ButtonGroupSize | undefined,
context: ButtonGroupContextValue | null
) {
return size ?? context?.size ?? "md";
}
function shouldApplyTheme(context: ButtonGroupContextValue | null) {
return !context?.connected;
}
type SeparatorProps = React.HTMLAttributes<HTMLDivElement> & {
decorative?: boolean;
orientation?: "horizontal" | "vertical";
};
function Separator({
className,
decorative = true,
orientation = "horizontal",
...props
}: SeparatorProps) {
return (
<div
aria-hidden={decorative || undefined}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-px w-full" : "h-full w-px",
className
)}
data-slot="separator"
role={decorative ? "presentation" : "separator"}
{...props}
/>
);
}
const buttonGroupVariants = cva(
"flex w-fit items-stretch overflow-hidden rounded-lg border border-border bg-background has-[>[data-slot=button-group-items]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-lg [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
{
variants: {
orientation: {
horizontal:
"[&>[data-slot]]:rounded-none [&>[data-slot]~[data-slot]]:border-border [&>[data-slot]~[data-slot]]:border-l",
vertical:
"flex-col [&>[data-slot]]:rounded-none [&>[data-slot]~[data-slot]]:border-border [&>[data-slot]~[data-slot]]:border-t",
},
},
defaultVariants: {
orientation: "horizontal",
},
}
);
const actionButtonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap py-0 font-medium outline-none transition-colors disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default:
"bg-background text-muted-foreground hover:bg-muted hover:text-foreground",
destructive:
"bg-background text-destructive hover:bg-destructive/10 hover:text-destructive",
ghost:
"bg-transparent text-muted-foreground hover:bg-muted hover:text-foreground",
outline:
"bg-background text-muted-foreground hover:bg-muted hover:text-foreground",
},
connected: {
true: "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
false:
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
},
},
defaultVariants: {
variant: "default",
connected: false,
},
}
);
const MotionButton = motion.button;
type Ripple = { id: string; x: number; y: number; size: number };
type MotionSafeButtonProps = Omit<
React.ButtonHTMLAttributes<HTMLButtonElement>,
| "onAnimationEnd"
| "onAnimationIteration"
| "onAnimationStart"
| "onDrag"
| "onDragEnd"
| "onDragStart"
>;
type ButtonGroupChild = React.ReactElement<MotionSafeButtonProps>;
const rootSizeStyles: Record<ButtonGroupSize, string> = {
sm: "min-h-8 rounded-md px-2.5 text-xs",
md: "min-h-9 rounded-lg px-3 text-sm",
lg: "min-h-10 rounded-lg px-4 text-sm",
};
const connectedRootSizeStyles: Record<ButtonGroupSize, string> = {
sm: "min-h-8 px-2.5 text-xs",
md: "min-h-9 px-3 text-sm",
lg: "min-h-10 px-4 text-sm",
};
const iconSizeStyles: Record<ButtonGroupSize, string> = {
sm: "size-8 rounded-md",
md: "size-9 rounded-lg",
lg: "size-10 rounded-lg",
};
const connectedIconSizeStyles: Record<ButtonGroupSize, string> = {
sm: "size-8",
md: "size-9",
lg: "size-10",
};
const textSizeStyles: Record<ButtonGroupSize, string> = {
sm: "px-2 text-xs",
md: "px-2.5 text-sm",
lg: "px-3 text-sm",
};
const groupItemSizeStyles: Record<ButtonGroupSize, string> = {
sm: "min-h-8 min-w-8 px-2.5 text-xs",
md: "min-h-9 min-w-9 px-3 text-sm",
lg: "min-h-10 min-w-10 px-4 text-sm",
};
const segmentedShellSizeStyles: Record<
ButtonGroupSize,
{ horizontal: string; vertical: string }
> = {
sm: {
horizontal: "gap-0.5 p-0.5",
vertical: "gap-0.5 p-0.5",
},
md: {
horizontal: "gap-0.5 p-0.5",
vertical: "gap-0.5 p-0.5",
},
lg: {
horizontal: "gap-1 p-1",
vertical: "gap-1 p-1",
},
};
const segmentedItemSizeStyles: Record<ButtonGroupSize, string> = {
sm: "min-h-7 flex-1 px-2.5 text-xs",
md: "min-h-8 flex-1 px-3 text-sm",
lg: "min-h-9 flex-1 px-4 text-sm",
};
const segmentedIndicatorTransition = {
type: "spring" as const,
stiffness: 170,
damping: 21,
mass: 0.9,
bounce: 0,
};
const contentSizeStyles: Record<ButtonGroupSize, string> = {
sm: "gap-1.5 [&_svg]:size-3.5 [&_svg]:shrink-0",
md: "gap-1.5 [&_svg]:size-4 [&_svg]:shrink-0",
lg: "gap-2 [&_svg]:size-4 [&_svg]:shrink-0",
};
interface ButtonProps extends MotionSafeButtonProps {
children: React.ReactNode;
disableRipple?: boolean;
showBorder?: boolean;
size?: ButtonGroupSize;
variant?: ButtonVariant;
}
interface RippleButtonProps extends MotionSafeButtonProps {
children: React.ReactNode;
className?: string;
contentClassName?: string;
disableRipple?: boolean;
themed?: boolean;
}
function getButtonGroupItemKey(child: ButtonGroupChild, index: number) {
if (child.key != null) {
return child.key;
}
const { children, name, value } = child.props;
const ariaLabel = child.props["aria-label"];
if (typeof value === "string" || typeof value === "number") {
return `value-${value}-${index}`;
}
if (typeof name === "string" && name.length > 0) {
return `name-${name}-${index}`;
}
if (typeof ariaLabel === "string" && ariaLabel.length > 0) {
return `label-${ariaLabel}-${index}`;
}
if (typeof children === "string" && children.trim().length > 0) {
return `text-${children.trim()}-${index}`;
}
return `button-group-item-${index}`;
}
const RippleButton = React.forwardRef<HTMLButtonElement, RippleButtonProps>(
(
{
className,
children,
contentClassName,
disabled,
disableRipple,
onPointerDown,
themed = true,
type = "button",
...props
},
ref
) => {
const [ripples, setRipples] = React.useState<Ripple[]>([]);
const handlePointerDown = (e: React.PointerEvent<HTMLButtonElement>) => {
onPointerDown?.(e);
if (disabled || disableRipple || e.button !== 0) {
return;
}
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const size =
2 *
Math.max(
Math.hypot(x, y),
Math.hypot(rect.width - x, y),
Math.hypot(x, rect.height - y),
Math.hypot(rect.width - x, rect.height - y)
);
const id =
typeof crypto !== "undefined" && crypto.randomUUID
? crypto.randomUUID()
: `${Date.now()}-${Math.random()}`;
setRipples((current) => [...current, { id, x, y, size }]);
};
return (
<MotionButton
className={cn(
themed && componentThemeClassName,
"relative cursor-pointer touch-manipulation select-none overflow-hidden",
className
)}
data-slot="button"
disabled={disabled}
onPointerDown={handlePointerDown}
ref={ref}
type={type}
{...props}
>
<span
aria-hidden
className="pointer-events-none absolute inset-0 z-0 overflow-hidden rounded-[inherit]"
>
{ripples.map((ripple) => (
<motion.span
animate={{ opacity: 0, scale: 1 }}
className="pointer-events-none absolute -translate-x-1/2 -translate-y-1/2 rounded-full bg-current"
initial={{ opacity: 0.16, scale: 0 }}
key={ripple.id}
onAnimationComplete={() => {
setRipples((current) =>
current.filter((item) => item.id !== ripple.id)
);
}}
style={{
height: ripple.size,
left: ripple.x,
top: ripple.y,
width: ripple.size,
}}
transition={{
duration: 0.55,
ease: [0.22, 1, 0.36, 1],
}}
/>
))}
</span>
<span
className={cn(
"relative z-10 inline-flex items-center gap-2",
contentClassName
)}
>
{children}
</span>
</MotionButton>
);
}
);
RippleButton.displayName = "RippleButton";
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
(
{
className,
children,
disableRipple = false,
showBorder = true,
size,
variant = "default",
disabled,
...props
},
ref
) => {
const groupContext = useButtonGroupContext();
const resolvedSize = resolveButtonGroupSize(size, groupContext);
const connected = groupContext?.connected ?? false;
const showStandaloneBorder =
!connected && variant !== "ghost" && showBorder;
return (
<RippleButton
className={cn(
actionButtonVariants({
variant,
connected,
}),
showStandaloneBorder && "border border-border",
connected
? connectedRootSizeStyles[resolvedSize]
: rootSizeStyles[resolvedSize],
className
)}
contentClassName={contentSizeStyles[resolvedSize]}
disabled={disabled}
disableRipple={disableRipple}
ref={ref}
themed={shouldApplyTheme(groupContext)}
{...props}
>
{children}
</RippleButton>
);
}
);
Button.displayName = "Button";
interface IconButtonProps extends MotionSafeButtonProps {
children: React.ReactNode;
disableRipple?: boolean;
showBorder?: boolean;
size?: ButtonGroupSize;
variant?: ButtonVariant;
}
const IconButton = React.forwardRef<HTMLButtonElement, IconButtonProps>(
(
{
className,
children,
disableRipple = false,
showBorder = true,
size,
variant = "default",
disabled,
...props
},
ref
) => {
const groupContext = useButtonGroupContext();
const resolvedSize = resolveButtonGroupSize(size, groupContext);
const connected = groupContext?.connected ?? false;
const showStandaloneBorder =
!connected && variant !== "ghost" && showBorder;
return (
<RippleButton
className={cn(
actionButtonVariants({
variant,
connected,
}),
showStandaloneBorder && "border border-border",
connected
? connectedIconSizeStyles[resolvedSize]
: iconSizeStyles[resolvedSize],
className
)}
contentClassName={contentSizeStyles[resolvedSize]}
disabled={disabled}
disableRipple={disableRipple}
ref={ref}
themed={shouldApplyTheme(groupContext)}
{...props}
>
{children}
</RippleButton>
);
}
);
IconButton.displayName = "IconButton";
type ButtonGroupProps = React.ComponentProps<"div"> &
VariantProps<typeof buttonGroupVariants> & {
size?: ButtonGroupSize;
};
function ButtonGroup({
className,
orientation,
role = "group",
size = "md",
children,
...props
}: ButtonGroupProps) {
const contextValue = React.useMemo(
() => ({
connected: true,
size,
}),
[size]
);
return (
<ButtonGroupContext.Provider value={contextValue}>
<div
className={cn(
componentThemeClassName,
buttonGroupVariants({ orientation }),
"*:focus-visible:relative *:focus-visible:z-10",
className
)}
data-orientation={orientation ?? "horizontal"}
data-slot="button-group"
role={role}
{...props}
>
{children}
</div>
</ButtonGroupContext.Provider>
);
}
function ButtonGroupText({
className,
render,
...props
}: useRender.ComponentProps<"div">) {
const groupContext = useButtonGroupContext();
const resolvedSize = resolveButtonGroupSize(undefined, groupContext);
const connected = groupContext?.connected ?? false;
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(
{
className: cn(
shouldApplyTheme(groupContext) && componentThemeClassName,
"flex items-center gap-2 font-medium text-muted-foreground [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none",
connected
? "rounded-none border-0 bg-muted"
: "rounded-lg border border-border bg-muted",
textSizeStyles[resolvedSize],
className
),
},
{
...props,
"data-slot": "button-group-text",
} as typeof props
),
render,
state: {
slot: "button-group-text",
},
});
}
function ButtonGroupSeparator({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
className={cn(
"relative self-stretch bg-input data-horizontal:mx-px data-vertical:my-px data-vertical:h-auto data-horizontal:w-auto",
className
)}
data-horizontal={orientation === "horizontal" ? "" : undefined}
data-orientation={orientation}
data-slot="button-group-separator"
data-vertical={orientation === "vertical" ? "" : undefined}
orientation={orientation}
{...props}
/>
);
}
interface ButtonGroupItemsProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
disableRipple?: boolean;
showDividers?: boolean;
size?: ButtonGroupSize;
}
type HoverFrame = {
height: number;
width: number;
x: number;
y: number;
};
function ButtonGroupItems({
children,
className,
disableRipple = false,
onPointerLeave,
role = "group",
showDividers = true,
size: sizeProp,
...props
}: ButtonGroupItemsProps) {
const groupContext = useButtonGroupContext();
const resolvedSize = resolveButtonGroupSize(sizeProp, groupContext);
const connected = groupContext?.connected ?? false;
const [activeHoverIndex, setActiveHoverIndex] = React.useState<number | null>(
null
);
const [hoverFrame, setHoverFrame] = React.useState<HoverFrame | null>(null);
const groupRef = React.useRef<HTMLDivElement | null>(null);
const itemRefs = React.useRef<Array<HTMLButtonElement | null>>([]);
const childArray = React.Children.toArray(children).filter(
(child): child is ButtonGroupChild =>
React.isValidElement<MotionSafeButtonProps>(child)
);
const usesSharedHover = !showDividers;
const updateHoverFrame = React.useCallback((index: number) => {
const group = groupRef.current;
const node = itemRefs.current[index];
if (!(group && node)) {
return;
}
const groupRect = group.getBoundingClientRect();
const nodeRect = node.getBoundingClientRect();
const next = {
height: nodeRect.height,
width: nodeRect.width,
x: nodeRect.left - groupRect.left,
y: nodeRect.top - groupRect.top,
};
setHoverFrame((current) => {
if (
current?.height === next.height &&
current?.width === next.width &&
current?.x === next.x &&
current?.y === next.y
) {
return current;
}
return next;
});
}, []);
React.useEffect(() => {
if (process.env.NODE_ENV === "production") {
return;
}
const allChildren = React.Children.toArray(children);
if (allChildren.length === 0) {
console.warn("[ButtonGroupItems] Pass at least one button-like child.");
return;
}
if (childArray.length !== allChildren.length) {
console.warn(
"[ButtonGroupItems] Only valid button-like React elements are rendered. Non-element children are ignored."
);
}
}, [childArray.length, children]);
React.useEffect(() => {
if (!usesSharedHover || activeHoverIndex === null) {
return;
}
const group = groupRef.current;
const activeNode = itemRefs.current[activeHoverIndex];
if (!group) {
return;
}
const handleMeasure = () => {
updateHoverFrame(activeHoverIndex);
};
const resizeObserver = new ResizeObserver(handleMeasure);
resizeObserver.observe(group);
if (activeNode) {
resizeObserver.observe(activeNode);
}
window.addEventListener("resize", handleMeasure);
return () => {
resizeObserver.disconnect();
window.removeEventListener("resize", handleMeasure);
};
}, [activeHoverIndex, updateHoverFrame, usesSharedHover]);
if (childArray.length === 0) {
return null;
}
return (
<div
className={cn(
shouldApplyTheme(groupContext) && componentThemeClassName,
"relative inline-flex items-stretch bg-background",
connected
? "rounded-none border-0"
: ["rounded-lg", showDividers && "border border-border"],
className
)}
data-slot="button-group-items"
onPointerLeave={(event) => {
onPointerLeave?.(event);
if (usesSharedHover) {
setActiveHoverIndex(null);
setHoverFrame(null);
}
}}
ref={groupRef}
role={role}
{...props}
>
{usesSharedHover && hoverFrame ? (
<motion.span
animate={{
height: hoverFrame.height,
left: hoverFrame.x,
opacity: 1,
top: hoverFrame.y,
width: hoverFrame.width,
}}
aria-hidden
className="pointer-events-none absolute top-0 left-0 z-0 rounded-md bg-muted/45"
initial={false}
transition={{
type: "spring",
stiffness: 420,
damping: 34,
mass: 0.8,
}}
/>
) : null}
{childArray.map((child, index) => {
const itemKey = getButtonGroupItemKey(child, index);
const isFirst = index === 0;
const isLast = index === childArray.length - 1;
const {
children: childChildren,
className: childClassName,
onBlur: childOnBlur,
onFocus: childOnFocus,
onPointerEnter: childOnPointerEnter,
onPointerLeave: childOnPointerLeave,
...childProps
} = child.props;
const showSharedHover = usesSharedHover && activeHoverIndex === index;
return (
<RippleButton
className={cn(
"relative inline-flex items-center justify-center whitespace-nowrap py-0 font-medium text-muted-foreground transition-colors hover:text-foreground",
"outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
"disabled:pointer-events-none disabled:opacity-50",
groupItemSizeStyles[resolvedSize],
showDividers
? [isFirst && "rounded-l-[7px]", isLast && "rounded-r-[7px]"]
: "rounded-md",
showSharedHover && "text-foreground",
!(usesSharedHover || showDividers) && "hover:bg-muted",
showDividers && "hover:bg-muted",
showDividers && !isLast && "border-border border-r",
childClassName
)}
contentClassName={contentSizeStyles[resolvedSize]}
disableRipple={disableRipple}
key={itemKey}
onBlur={(event) => {
childOnBlur?.(event);
if (
groupRef.current?.contains(event.relatedTarget as Node | null)
) {
return;
}
setActiveHoverIndex((current) =>
current === index ? null : current
);
setHoverFrame(null);
}}
onFocus={(event) => {
childOnFocus?.(event);
if (usesSharedHover) {
setActiveHoverIndex(index);
updateHoverFrame(index);
}
}}
onPointerEnter={(event) => {
childOnPointerEnter?.(event);
if (usesSharedHover) {
setActiveHoverIndex(index);
updateHoverFrame(index);
}
}}
onPointerLeave={childOnPointerLeave}
ref={(node) => {
itemRefs.current[index] = node;
}}
themed={false}
{...childProps}
>
{childChildren}
</RippleButton>
);
})}
</div>
);
}
type SegmentedControlOptionObject = {
value: string;
label?: React.ReactNode;
disabled?: boolean;
icon?: React.ReactNode;
};
type SegmentedControlOption = string | SegmentedControlOptionObject;
type NormalizedSegmentedOption = {
value: string;
label: React.ReactNode;
disabled?: boolean;
icon?: React.ReactNode;
};
interface SegmentedControlProps {
options: SegmentedControlOption[];
value?: string;
onChange?: (value: string) => void;
ariaLabel?: string;
ariaLabelledBy?: string;
className?: string;
disabled?: boolean;
layoutId?: string;
name?: string;
orientation?: "horizontal" | "vertical";
size?: ButtonGroupSize;
}
function normalizeSegmentedOptions(
options: SegmentedControlOption[]
): NormalizedSegmentedOption[] {
return options.map((option) => {
if (typeof option === "string") {
return {
value: option,
label: option,
};
}
return {
value: option.value,
label: option.label ?? option.value,
disabled: option.disabled,
icon: option.icon,
};
});
}
function getEnabledOptionIndex(
options: NormalizedSegmentedOption[],
startIndex: number,
direction: 1 | -1
) {
if (options.length === 0) {
return -1;
}
let index = startIndex;
for (const _option of options) {
index = (index + direction + options.length) % options.length;
if (!options[index]?.disabled) {
return index;
}
}
return -1;
}
function isRtlElement(element: HTMLElement | null) {
if (!element) {
return false;
}
const dir = window.getComputedStyle(element).direction;
return dir === "rtl";
}
function SegmentedControl({
options,
value,
onChange,
ariaLabel,
ariaLabelledBy,
className,
disabled = false,
layoutId,
name,
orientation = "horizontal",
size = "md",
}: SegmentedControlProps) {
const instanceId = React.useId();
const resolvedLayoutId = layoutId ?? `segmented-indicator-${instanceId}`;
const normalizedOptions = React.useMemo(
() => normalizeSegmentedOptions(options),
[options]
);
const initialValue = normalizedOptions.find(
(option) => !option.disabled
)?.value;
const [selected, setSelected] = React.useState(value ?? initialValue ?? "");
const [isHovered, setIsHovered] = React.useState<string | null>(null);
const buttonRefs = React.useRef<Array<HTMLButtonElement | null>>([]);
const groupRef = React.useRef<HTMLDivElement | null>(null);
const resolvedSelected =
normalizedOptions.find(
(option) => option.value === selected && !option.disabled
)?.value ??
normalizedOptions.find((option) => !option.disabled)?.value ??
"";
React.useEffect(() => {
if (process.env.NODE_ENV === "production") {
return;
}
if (!(ariaLabel || ariaLabelledBy)) {
console.warn(
"[SegmentedControl] Provide ariaLabel or ariaLabelledBy for accessibility."
);
}
const values = normalizedOptions.map((option) => option.value);
const uniqueValues = new Set(values);
if (uniqueValues.size !== values.length) {
console.warn(
"[SegmentedControl] Option values must be unique. Duplicate values can break selection."
);
}
}, [ariaLabel, ariaLabelledBy, normalizedOptions]);
React.useEffect(() => {
buttonRefs.current.length = normalizedOptions.length;
}, [normalizedOptions.length]);
React.useEffect(() => {
if (value !== undefined) {
const next =
normalizedOptions.find(
(option) => option.value === value && !option.disabled
)?.value ??
normalizedOptions.find((option) => !option.disabled)?.value ??
"";
setSelected((current) => (current === next ? current : next));
return;
}
setSelected((current) => {
const stillValid = normalizedOptions.some(
(option) => option.value === current && !option.disabled
);
if (stillValid) {
return current;
}
return normalizedOptions.find((option) => !option.disabled)?.value ?? "";
});
}, [normalizedOptions, value]);
const handleSelect = (nextValue: string) => {
const option = normalizedOptions.find((item) => item.value === nextValue);
if (!option || option.disabled || disabled) {
return;
}
if (nextValue === resolvedSelected) {
return;
}
setSelected(nextValue);
onChange?.(nextValue);
};
const focusOption = (index: number) => {
buttonRefs.current[index]?.focus();
};
const moveSelection = (index: number) => {
const option = normalizedOptions[index];
if (!option || option.disabled) {
return;
}
handleSelect(option.value);
focusOption(index);
};
const moveByDirection = (index: number, direction: 1 | -1) => {
const nextIndex = getEnabledOptionIndex(
normalizedOptions,
index,
direction
);
if (nextIndex === -1) {
return;
}
moveSelection(nextIndex);
};
const handleKeyDown = (
event: React.KeyboardEvent<HTMLButtonElement>,
index: number
) => {
const rtl = isRtlElement(groupRef.current);
const horizontalNext = rtl ? -1 : 1;
const horizontalPrevious = rtl ? 1 : -1;
switch (event.key) {
case "ArrowRight": {
event.preventDefault();
moveByDirection(
index,
orientation === "horizontal" ? horizontalNext : 1
);
break;
}
case "ArrowLeft": {
event.preventDefault();
moveByDirection(
index,
orientation === "horizontal" ? horizontalPrevious : -1
);
break;
}
case "ArrowDown": {
event.preventDefault();
moveByDirection(index, orientation === "vertical" ? 1 : horizontalNext);
break;
}
case "ArrowUp": {
event.preventDefault();
moveByDirection(
index,
orientation === "vertical" ? -1 : horizontalPrevious
);
break;
}
case "Home": {
event.preventDefault();
moveSelection(getEnabledOptionIndex(normalizedOptions, -1, 1));
break;
}
case "End": {
event.preventDefault();
moveSelection(getEnabledOptionIndex(normalizedOptions, 0, -1));
break;
}
default:
break;
}
};
if (normalizedOptions.length === 0) {
return null;
}
return (
<div
aria-disabled={disabled || undefined}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
aria-orientation={orientation}
className={cn(
componentThemeClassName,
"relative inline-flex items-stretch rounded-lg border border-border bg-background",
orientation === "vertical" ? "flex-col" : "flex-row",
segmentedShellSizeStyles[size][orientation],
disabled && "pointer-events-none opacity-60",
className
)}
ref={groupRef}
role="radiogroup"
>
{name && !disabled ? (
<input name={name} type="hidden" value={resolvedSelected} />
) : null}
<LayoutGroup id={resolvedLayoutId}>
{normalizedOptions.map((option, index) => {
const isSelected = resolvedSelected === option.value;
const showHover = isHovered === option.value && !isSelected;
const isOptionDisabled = disabled || option.disabled;
return (
<motion.button
aria-checked={isSelected}
aria-disabled={isOptionDisabled || undefined}
className={cn(
"relative z-10 inline-flex cursor-pointer touch-manipulation select-none items-center justify-center whitespace-nowrap rounded-md py-0 font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
segmentedItemSizeStyles[size],
isSelected ? "text-foreground" : "text-muted-foreground",
isOptionDisabled && "cursor-not-allowed opacity-50"
)}
disabled={isOptionDisabled}
key={option.value}
onClick={() => handleSelect(option.value)}
onKeyDown={(event) => handleKeyDown(event, index)}
onPointerEnter={() => {
if (!isOptionDisabled) {
setIsHovered(option.value);
}
}}
onPointerLeave={() =>
setIsHovered((current) =>
current === option.value ? null : current
)
}
ref={(node) => {
buttonRefs.current[index] = node;
}}
role="radio"
tabIndex={isSelected && !isOptionDisabled ? 0 : -1}
type="button"
whileTap={isOptionDisabled ? undefined : { scale: 0.96 }}
>
{isSelected ? (
<motion.span
aria-hidden
className="absolute inset-0 z-0 rounded-md bg-accent will-change-[transform,width,height]"
initial={false}
layoutId={resolvedLayoutId}
transition={segmentedIndicatorTransition}
/>
) : null}
<motion.span
animate={{
opacity: showHover ? 1 : 0,
}}
aria-hidden
className="pointer-events-none absolute inset-0 z-0 rounded-md bg-muted/40"
initial={false}
transition={{
duration: 0.24,
ease: [0.22, 1, 0.36, 1],
}}
/>
<motion.span
animate={{
opacity: isSelected ? 1 : 0.72,
}}
className="relative z-10 inline-flex items-center gap-1.5"
transition={{
duration: 0.28,
ease: [0.22, 1, 0.36, 1],
}}
>
{option.icon}
{option.label}
</motion.span>
</motion.button>
);
})}
</LayoutGroup>
</div>
);
}
export {
Button,
IconButton,
ButtonGroup,
ButtonGroupItems,
ButtonGroupSeparator,
ButtonGroupText,
SegmentedControl,
actionButtonVariants,
buttonGroupVariants,
};
export type {
ButtonGroupSize,
ButtonVariant,
ButtonProps,
IconButtonProps,
ButtonGroupProps,
ButtonGroupItemsProps,
SegmentedControlProps,
SegmentedControlOption,
SegmentedControlOptionObject,
};
Button Group
Try the interaction, copy the React source files, or download the complete example.
About Button Group
Button Group is an animated React and Tailwind CSS button by Edwin Vakayil. Try the interaction, copy the React source, or download the example with creator credit.
- Effects and categories
- Hover effects, Animated
- Using this example
- Copy a TSX file or download the original source and standalone demo. Test keyboard focus, motion and contrast in your own project.
Usage, Tailwind and licensing questions
Read the source files
import-examples/button-group.tsx
import {Button,ButtonGroup} from "@/registry/button-group";export default function Demo(){return <ButtonGroup><Button>Previous</Button><Button>Next</Button></ButtonGroup>}registry/button-group.tsx
"use client";
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { cva, type VariantProps } from "class-variance-authority";
import { LayoutGroup, motion } from "motion/react";
import * as React from "react";
import { cn } from "@/lib/utils";
const componentThemeClassName =
"[--ic-background:#ffffff] [--ic-foreground:#111111] [--ic-primary:#111111] [--ic-secondary:#646b75] [--ic-surface-border:#e9edf2] [--ic-border:#e3e7ec] [--ic-card:#ffffff] [--ic-card-foreground:#111111] [--ic-muted:#f5f7fa] [--ic-muted-foreground:#6d7480] [--ic-accent:#f3f5f8] [--color-accent:var(--ic-accent)] [--color-accent-foreground:var(--ic-accent-foreground)] [--ic-accent-foreground:#111111] [--ic-input:#e3e7ec] [--ic-ring:rgba(17,17,17,0.16)] [--ic-destructive:#dc2626] [--ic-paper:#fcfcfd] [--ic-popover-foreground:#111111] [--ic-brand:#0ea5e9] [--ic-brand-soft:#bae6fd] [--ic-shadow-soft:0_18px_38px_-24px_rgba(15,23,42,0.35)] [--ic-chart-1:oklch(0.52_0.19_254)] [--ic-chart-2:oklch(0.74_0.11_232)] [--ic-chart-3:oklch(0.42_0.16_262)] [--ic-chart-4:oklch(0.84_0.07_228)] [--ic-chart-5:oklch(0.62_0.14_240)] [--color-background:var(--ic-background)] [--color-foreground:var(--ic-foreground)] [--color-primary:var(--ic-primary)] [--color-secondary:var(--ic-secondary)] [--color-border:var(--ic-border)] [--color-card:var(--ic-card)] [--color-card-foreground:var(--ic-card-foreground)] [--color-muted:var(--ic-muted)] [--color-muted-foreground:var(--ic-muted-foreground)] [--color-accent:var(--ic-accent)] [--color-accent-foreground:var(--ic-accent-foreground)] [--color-input:var(--ic-input)] [--color-ring:var(--ic-ring)] [--color-destructive:var(--ic-destructive)] [--color-paper:var(--ic-paper)] [--color-popover-foreground:var(--ic-popover-foreground)] [--color-brand:var(--ic-brand)] [--color-brand-soft:var(--ic-brand-soft)] [--color-chart-1:var(--ic-chart-1)] [--color-chart-2:var(--ic-chart-2)] [--color-chart-3:var(--ic-chart-3)] [--color-chart-4:var(--ic-chart-4)] [--color-chart-5:var(--ic-chart-5)] dark:[--ic-background:#111111] dark:[--ic-foreground:#f6f3ec] dark:[--ic-primary:#f6f3ec] dark:[--ic-secondary:#cbc6bb] dark:[--ic-surface-border:#2a2a25] dark:[--ic-border:#2b2a25] dark:[--ic-card:#111111] dark:[--ic-card-foreground:#f6f3ec] dark:[--ic-muted:#171716] dark:[--ic-muted-foreground:#9a958a] dark:[--ic-accent:#1a1a18] [--color-accent:var(--ic-accent)] [--color-accent-foreground:var(--ic-accent-foreground)] dark:[--ic-accent-foreground:#f6f3ec] dark:[--ic-input:#2b2a25] dark:[--ic-ring:rgba(246,243,236,0.18)] dark:[--ic-destructive:#f87171] dark:[--ic-paper:#171716] dark:[--ic-popover-foreground:#f6f3ec] dark:[--ic-brand:#38bdf8] dark:[--ic-brand-soft:#0c4a6e] dark:[--ic-shadow-soft:0_20px_44px_-28px_rgba(0,0,0,0.6)] dark:[--ic-chart-1:oklch(0.68_0.17_250)] dark:[--ic-chart-2:oklch(0.82_0.09_225)] dark:[--ic-chart-3:oklch(0.58_0.15_260)] dark:[--ic-chart-4:oklch(0.75_0.12_235)] dark:[--ic-chart-5:oklch(0.88_0.06_220)]";
type ButtonGroupSize = "sm" | "md" | "lg";
type ButtonVariant = "default" | "destructive" | "ghost" | "outline";
type ButtonGroupContextValue = {
connected: boolean;
size: ButtonGroupSize;
};
const ButtonGroupContext = React.createContext<ButtonGroupContextValue | null>(
null
);
function useButtonGroupContext() {
return React.useContext(ButtonGroupContext);
}
function resolveButtonGroupSize(
size: ButtonGroupSize | undefined,
context: ButtonGroupContextValue | null
) {
return size ?? context?.size ?? "md";
}
function shouldApplyTheme(context: ButtonGroupContextValue | null) {
return !context?.connected;
}
type SeparatorProps = React.HTMLAttributes<HTMLDivElement> & {
decorative?: boolean;
orientation?: "horizontal" | "vertical";
};
function Separator({
className,
decorative = true,
orientation = "horizontal",
...props
}: SeparatorProps) {
return (
<div
aria-hidden={decorative || undefined}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-px w-full" : "h-full w-px",
className
)}
data-slot="separator"
role={decorative ? "presentation" : "separator"}
{...props}
/>
);
}
const buttonGroupVariants = cva(
"flex w-fit items-stretch overflow-hidden rounded-lg border border-border bg-background has-[>[data-slot=button-group-items]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-lg [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
{
variants: {
orientation: {
horizontal:
"[&>[data-slot]]:rounded-none [&>[data-slot]~[data-slot]]:border-border [&>[data-slot]~[data-slot]]:border-l",
vertical:
"flex-col [&>[data-slot]]:rounded-none [&>[data-slot]~[data-slot]]:border-border [&>[data-slot]~[data-slot]]:border-t",
},
},
defaultVariants: {
orientation: "horizontal",
},
}
);
const actionButtonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap py-0 font-medium outline-none transition-colors disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default:
"bg-background text-muted-foreground hover:bg-muted hover:text-foreground",
destructive:
"bg-background text-destructive hover:bg-destructive/10 hover:text-destructive",
ghost:
"bg-transparent text-muted-foreground hover:bg-muted hover:text-foreground",
outline:
"bg-background text-muted-foreground hover:bg-muted hover:text-foreground",
},
connected: {
true: "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
false:
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
},
},
defaultVariants: {
variant: "default",
connected: false,
},
}
);
const MotionButton = motion.button;
type Ripple = { id: string; x: number; y: number; size: number };
type MotionSafeButtonProps = Omit<
React.ButtonHTMLAttributes<HTMLButtonElement>,
| "onAnimationEnd"
| "onAnimationIteration"
| "onAnimationStart"
| "onDrag"
| "onDragEnd"
| "onDragStart"
>;
type ButtonGroupChild = React.ReactElement<MotionSafeButtonProps>;
const rootSizeStyles: Record<ButtonGroupSize, string> = {
sm: "min-h-8 rounded-md px-2.5 text-xs",
md: "min-h-9 rounded-lg px-3 text-sm",
lg: "min-h-10 rounded-lg px-4 text-sm",
};
const connectedRootSizeStyles: Record<ButtonGroupSize, string> = {
sm: "min-h-8 px-2.5 text-xs",
md: "min-h-9 px-3 text-sm",
lg: "min-h-10 px-4 text-sm",
};
const iconSizeStyles: Record<ButtonGroupSize, string> = {
sm: "size-8 rounded-md",
md: "size-9 rounded-lg",
lg: "size-10 rounded-lg",
};
const connectedIconSizeStyles: Record<ButtonGroupSize, string> = {
sm: "size-8",
md: "size-9",
lg: "size-10",
};
const textSizeStyles: Record<ButtonGroupSize, string> = {
sm: "px-2 text-xs",
md: "px-2.5 text-sm",
lg: "px-3 text-sm",
};
const groupItemSizeStyles: Record<ButtonGroupSize, string> = {
sm: "min-h-8 min-w-8 px-2.5 text-xs",
md: "min-h-9 min-w-9 px-3 text-sm",
lg: "min-h-10 min-w-10 px-4 text-sm",
};
const segmentedShellSizeStyles: Record<
ButtonGroupSize,
{ horizontal: string; vertical: string }
> = {
sm: {
horizontal: "gap-0.5 p-0.5",
vertical: "gap-0.5 p-0.5",
},
md: {
horizontal: "gap-0.5 p-0.5",
vertical: "gap-0.5 p-0.5",
},
lg: {
horizontal: "gap-1 p-1",
vertical: "gap-1 p-1",
},
};
const segmentedItemSizeStyles: Record<ButtonGroupSize, string> = {
sm: "min-h-7 flex-1 px-2.5 text-xs",
md: "min-h-8 flex-1 px-3 text-sm",
lg: "min-h-9 flex-1 px-4 text-sm",
};
const segmentedIndicatorTransition = {
type: "spring" as const,
stiffness: 170,
damping: 21,
mass: 0.9,
bounce: 0,
};
const contentSizeStyles: Record<ButtonGroupSize, string> = {
sm: "gap-1.5 [&_svg]:size-3.5 [&_svg]:shrink-0",
md: "gap-1.5 [&_svg]:size-4 [&_svg]:shrink-0",
lg: "gap-2 [&_svg]:size-4 [&_svg]:shrink-0",
};
interface ButtonProps extends MotionSafeButtonProps {
children: React.ReactNode;
disableRipple?: boolean;
showBorder?: boolean;
size?: ButtonGroupSize;
variant?: ButtonVariant;
}
interface RippleButtonProps extends MotionSafeButtonProps {
children: React.ReactNode;
className?: string;
contentClassName?: string;
disableRipple?: boolean;
themed?: boolean;
}
function getButtonGroupItemKey(child: ButtonGroupChild, index: number) {
if (child.key != null) {
return child.key;
}
const { children, name, value } = child.props;
const ariaLabel = child.props["aria-label"];
if (typeof value === "string" || typeof value === "number") {
return `value-${value}-${index}`;
}
if (typeof name === "string" && name.length > 0) {
return `name-${name}-${index}`;
}
if (typeof ariaLabel === "string" && ariaLabel.length > 0) {
return `label-${ariaLabel}-${index}`;
}
if (typeof children === "string" && children.trim().length > 0) {
return `text-${children.trim()}-${index}`;
}
return `button-group-item-${index}`;
}
const RippleButton = React.forwardRef<HTMLButtonElement, RippleButtonProps>(
(
{
className,
children,
contentClassName,
disabled,
disableRipple,
onPointerDown,
themed = true,
type = "button",
...props
},
ref
) => {
const [ripples, setRipples] = React.useState<Ripple[]>([]);
const handlePointerDown = (e: React.PointerEvent<HTMLButtonElement>) => {
onPointerDown?.(e);
if (disabled || disableRipple || e.button !== 0) {
return;
}
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const size =
2 *
Math.max(
Math.hypot(x, y),
Math.hypot(rect.width - x, y),
Math.hypot(x, rect.height - y),
Math.hypot(rect.width - x, rect.height - y)
);
const id =
typeof crypto !== "undefined" && crypto.randomUUID
? crypto.randomUUID()
: `${Date.now()}-${Math.random()}`;
setRipples((current) => [...current, { id, x, y, size }]);
};
return (
<MotionButton
className={cn(
themed && componentThemeClassName,
"relative cursor-pointer touch-manipulation select-none overflow-hidden",
className
)}
data-slot="button"
disabled={disabled}
onPointerDown={handlePointerDown}
ref={ref}
type={type}
{...props}
>
<span
aria-hidden
className="pointer-events-none absolute inset-0 z-0 overflow-hidden rounded-[inherit]"
>
{ripples.map((ripple) => (
<motion.span
animate={{ opacity: 0, scale: 1 }}
className="pointer-events-none absolute -translate-x-1/2 -translate-y-1/2 rounded-full bg-current"
initial={{ opacity: 0.16, scale: 0 }}
key={ripple.id}
onAnimationComplete={() => {
setRipples((current) =>
current.filter((item) => item.id !== ripple.id)
);
}}
style={{
height: ripple.size,
left: ripple.x,
top: ripple.y,
width: ripple.size,
}}
transition={{
duration: 0.55,
ease: [0.22, 1, 0.36, 1],
}}
/>
))}
</span>
<span
className={cn(
"relative z-10 inline-flex items-center gap-2",
contentClassName
)}
>
{children}
</span>
</MotionButton>
);
}
);
RippleButton.displayName = "RippleButton";
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
(
{
className,
children,
disableRipple = false,
showBorder = true,
size,
variant = "default",
disabled,
...props
},
ref
) => {
const groupContext = useButtonGroupContext();
const resolvedSize = resolveButtonGroupSize(size, groupContext);
const connected = groupContext?.connected ?? false;
const showStandaloneBorder =
!connected && variant !== "ghost" && showBorder;
return (
<RippleButton
className={cn(
actionButtonVariants({
variant,
connected,
}),
showStandaloneBorder && "border border-border",
connected
? connectedRootSizeStyles[resolvedSize]
: rootSizeStyles[resolvedSize],
className
)}
contentClassName={contentSizeStyles[resolvedSize]}
disabled={disabled}
disableRipple={disableRipple}
ref={ref}
themed={shouldApplyTheme(groupContext)}
{...props}
>
{children}
</RippleButton>
);
}
);
Button.displayName = "Button";
interface IconButtonProps extends MotionSafeButtonProps {
children: React.ReactNode;
disableRipple?: boolean;
showBorder?: boolean;
size?: ButtonGroupSize;
variant?: ButtonVariant;
}
const IconButton = React.forwardRef<HTMLButtonElement, IconButtonProps>(
(
{
className,
children,
disableRipple = false,
showBorder = true,
size,
variant = "default",
disabled,
...props
},
ref
) => {
const groupContext = useButtonGroupContext();
const resolvedSize = resolveButtonGroupSize(size, groupContext);
const connected = groupContext?.connected ?? false;
const showStandaloneBorder =
!connected && variant !== "ghost" && showBorder;
return (
<RippleButton
className={cn(
actionButtonVariants({
variant,
connected,
}),
showStandaloneBorder && "border border-border",
connected
? connectedIconSizeStyles[resolvedSize]
: iconSizeStyles[resolvedSize],
className
)}
contentClassName={contentSizeStyles[resolvedSize]}
disabled={disabled}
disableRipple={disableRipple}
ref={ref}
themed={shouldApplyTheme(groupContext)}
{...props}
>
{children}
</RippleButton>
);
}
);
IconButton.displayName = "IconButton";
type ButtonGroupProps = React.ComponentProps<"div"> &
VariantProps<typeof buttonGroupVariants> & {
size?: ButtonGroupSize;
};
function ButtonGroup({
className,
orientation,
role = "group",
size = "md",
children,
...props
}: ButtonGroupProps) {
const contextValue = React.useMemo(
() => ({
connected: true,
size,
}),
[size]
);
return (
<ButtonGroupContext.Provider value={contextValue}>
<div
className={cn(
componentThemeClassName,
buttonGroupVariants({ orientation }),
"*:focus-visible:relative *:focus-visible:z-10",
className
)}
data-orientation={orientation ?? "horizontal"}
data-slot="button-group"
role={role}
{...props}
>
{children}
</div>
</ButtonGroupContext.Provider>
);
}
function ButtonGroupText({
className,
render,
...props
}: useRender.ComponentProps<"div">) {
const groupContext = useButtonGroupContext();
const resolvedSize = resolveButtonGroupSize(undefined, groupContext);
const connected = groupContext?.connected ?? false;
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(
{
className: cn(
shouldApplyTheme(groupContext) && componentThemeClassName,
"flex items-center gap-2 font-medium text-muted-foreground [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none",
connected
? "rounded-none border-0 bg-muted"
: "rounded-lg border border-border bg-muted",
textSizeStyles[resolvedSize],
className
),
},
{
...props,
"data-slot": "button-group-text",
} as typeof props
),
render,
state: {
slot: "button-group-text",
},
});
}
function ButtonGroupSeparator({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
className={cn(
"relative self-stretch bg-input data-horizontal:mx-px data-vertical:my-px data-vertical:h-auto data-horizontal:w-auto",
className
)}
data-horizontal={orientation === "horizontal" ? "" : undefined}
data-orientation={orientation}
data-slot="button-group-separator"
data-vertical={orientation === "vertical" ? "" : undefined}
orientation={orientation}
{...props}
/>
);
}
interface ButtonGroupItemsProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
disableRipple?: boolean;
showDividers?: boolean;
size?: ButtonGroupSize;
}
type HoverFrame = {
height: number;
width: number;
x: number;
y: number;
};
function ButtonGroupItems({
children,
className,
disableRipple = false,
onPointerLeave,
role = "group",
showDividers = true,
size: sizeProp,
...props
}: ButtonGroupItemsProps) {
const groupContext = useButtonGroupContext();
const resolvedSize = resolveButtonGroupSize(sizeProp, groupContext);
const connected = groupContext?.connected ?? false;
const [activeHoverIndex, setActiveHoverIndex] = React.useState<number | null>(
null
);
const [hoverFrame, setHoverFrame] = React.useState<HoverFrame | null>(null);
const groupRef = React.useRef<HTMLDivElement | null>(null);
const itemRefs = React.useRef<Array<HTMLButtonElement | null>>([]);
const childArray = React.Children.toArray(children).filter(
(child): child is ButtonGroupChild =>
React.isValidElement<MotionSafeButtonProps>(child)
);
const usesSharedHover = !showDividers;
const updateHoverFrame = React.useCallback((index: number) => {
const group = groupRef.current;
const node = itemRefs.current[index];
if (!(group && node)) {
return;
}
const groupRect = group.getBoundingClientRect();
const nodeRect = node.getBoundingClientRect();
const next = {
height: nodeRect.height,
width: nodeRect.width,
x: nodeRect.left - groupRect.left,
y: nodeRect.top - groupRect.top,
};
setHoverFrame((current) => {
if (
current?.height === next.height &&
current?.width === next.width &&
current?.x === next.x &&
current?.y === next.y
) {
return current;
}
return next;
});
}, []);
React.useEffect(() => {
if (process.env.NODE_ENV === "production") {
return;
}
const allChildren = React.Children.toArray(children);
if (allChildren.length === 0) {
console.warn("[ButtonGroupItems] Pass at least one button-like child.");
return;
}
if (childArray.length !== allChildren.length) {
console.warn(
"[ButtonGroupItems] Only valid button-like React elements are rendered. Non-element children are ignored."
);
}
}, [childArray.length, children]);
React.useEffect(() => {
if (!usesSharedHover || activeHoverIndex === null) {
return;
}
const group = groupRef.current;
const activeNode = itemRefs.current[activeHoverIndex];
if (!group) {
return;
}
const handleMeasure = () => {
updateHoverFrame(activeHoverIndex);
};
const resizeObserver = new ResizeObserver(handleMeasure);
resizeObserver.observe(group);
if (activeNode) {
resizeObserver.observe(activeNode);
}
window.addEventListener("resize", handleMeasure);
return () => {
resizeObserver.disconnect();
window.removeEventListener("resize", handleMeasure);
};
}, [activeHoverIndex, updateHoverFrame, usesSharedHover]);
if (childArray.length === 0) {
return null;
}
return (
<div
className={cn(
shouldApplyTheme(groupContext) && componentThemeClassName,
"relative inline-flex items-stretch bg-background",
connected
? "rounded-none border-0"
: ["rounded-lg", showDividers && "border border-border"],
className
)}
data-slot="button-group-items"
onPointerLeave={(event) => {
onPointerLeave?.(event);
if (usesSharedHover) {
setActiveHoverIndex(null);
setHoverFrame(null);
}
}}
ref={groupRef}
role={role}
{...props}
>
{usesSharedHover && hoverFrame ? (
<motion.span
animate={{
height: hoverFrame.height,
left: hoverFrame.x,
opacity: 1,
top: hoverFrame.y,
width: hoverFrame.width,
}}
aria-hidden
className="pointer-events-none absolute top-0 left-0 z-0 rounded-md bg-muted/45"
initial={false}
transition={{
type: "spring",
stiffness: 420,
damping: 34,
mass: 0.8,
}}
/>
) : null}
{childArray.map((child, index) => {
const itemKey = getButtonGroupItemKey(child, index);
const isFirst = index === 0;
const isLast = index === childArray.length - 1;
const {
children: childChildren,
className: childClassName,
onBlur: childOnBlur,
onFocus: childOnFocus,
onPointerEnter: childOnPointerEnter,
onPointerLeave: childOnPointerLeave,
...childProps
} = child.props;
const showSharedHover = usesSharedHover && activeHoverIndex === index;
return (
<RippleButton
className={cn(
"relative inline-flex items-center justify-center whitespace-nowrap py-0 font-medium text-muted-foreground transition-colors hover:text-foreground",
"outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
"disabled:pointer-events-none disabled:opacity-50",
groupItemSizeStyles[resolvedSize],
showDividers
? [isFirst && "rounded-l-[7px]", isLast && "rounded-r-[7px]"]
: "rounded-md",
showSharedHover && "text-foreground",
!(usesSharedHover || showDividers) && "hover:bg-muted",
showDividers && "hover:bg-muted",
showDividers && !isLast && "border-border border-r",
childClassName
)}
contentClassName={contentSizeStyles[resolvedSize]}
disableRipple={disableRipple}
key={itemKey}
onBlur={(event) => {
childOnBlur?.(event);
if (
groupRef.current?.contains(event.relatedTarget as Node | null)
) {
return;
}
setActiveHoverIndex((current) =>
current === index ? null : current
);
setHoverFrame(null);
}}
onFocus={(event) => {
childOnFocus?.(event);
if (usesSharedHover) {
setActiveHoverIndex(index);
updateHoverFrame(index);
}
}}
onPointerEnter={(event) => {
childOnPointerEnter?.(event);
if (usesSharedHover) {
setActiveHoverIndex(index);
updateHoverFrame(index);
}
}}
onPointerLeave={childOnPointerLeave}
ref={(node) => {
itemRefs.current[index] = node;
}}
themed={false}
{...childProps}
>
{childChildren}
</RippleButton>
);
})}
</div>
);
}
type SegmentedControlOptionObject = {
value: string;
label?: React.ReactNode;
disabled?: boolean;
icon?: React.ReactNode;
};
type SegmentedControlOption = string | SegmentedControlOptionObject;
type NormalizedSegmentedOption = {
value: string;
label: React.ReactNode;
disabled?: boolean;
icon?: React.ReactNode;
};
interface SegmentedControlProps {
options: SegmentedControlOption[];
value?: string;
onChange?: (value: string) => void;
ariaLabel?: string;
ariaLabelledBy?: string;
className?: string;
disabled?: boolean;
layoutId?: string;
name?: string;
orientation?: "horizontal" | "vertical";
size?: ButtonGroupSize;
}
function normalizeSegmentedOptions(
options: SegmentedControlOption[]
): NormalizedSegmentedOption[] {
return options.map((option) => {
if (typeof option === "string") {
return {
value: option,
label: option,
};
}
return {
value: option.value,
label: option.label ?? option.value,
disabled: option.disabled,
icon: option.icon,
};
});
}
function getEnabledOptionIndex(
options: NormalizedSegmentedOption[],
startIndex: number,
direction: 1 | -1
) {
if (options.length === 0) {
return -1;
}
let index = startIndex;
for (const _option of options) {
index = (index + direction + options.length) % options.length;
if (!options[index]?.disabled) {
return index;
}
}
return -1;
}
function isRtlElement(element: HTMLElement | null) {
if (!element) {
return false;
}
const dir = window.getComputedStyle(element).direction;
return dir === "rtl";
}
function SegmentedControl({
options,
value,
onChange,
ariaLabel,
ariaLabelledBy,
className,
disabled = false,
layoutId,
name,
orientation = "horizontal",
size = "md",
}: SegmentedControlProps) {
const instanceId = React.useId();
const resolvedLayoutId = layoutId ?? `segmented-indicator-${instanceId}`;
const normalizedOptions = React.useMemo(
() => normalizeSegmentedOptions(options),
[options]
);
const initialValue = normalizedOptions.find(
(option) => !option.disabled
)?.value;
const [selected, setSelected] = React.useState(value ?? initialValue ?? "");
const [isHovered, setIsHovered] = React.useState<string | null>(null);
const buttonRefs = React.useRef<Array<HTMLButtonElement | null>>([]);
const groupRef = React.useRef<HTMLDivElement | null>(null);
const resolvedSelected =
normalizedOptions.find(
(option) => option.value === selected && !option.disabled
)?.value ??
normalizedOptions.find((option) => !option.disabled)?.value ??
"";
React.useEffect(() => {
if (process.env.NODE_ENV === "production") {
return;
}
if (!(ariaLabel || ariaLabelledBy)) {
console.warn(
"[SegmentedControl] Provide ariaLabel or ariaLabelledBy for accessibility."
);
}
const values = normalizedOptions.map((option) => option.value);
const uniqueValues = new Set(values);
if (uniqueValues.size !== values.length) {
console.warn(
"[SegmentedControl] Option values must be unique. Duplicate values can break selection."
);
}
}, [ariaLabel, ariaLabelledBy, normalizedOptions]);
React.useEffect(() => {
buttonRefs.current.length = normalizedOptions.length;
}, [normalizedOptions.length]);
React.useEffect(() => {
if (value !== undefined) {
const next =
normalizedOptions.find(
(option) => option.value === value && !option.disabled
)?.value ??
normalizedOptions.find((option) => !option.disabled)?.value ??
"";
setSelected((current) => (current === next ? current : next));
return;
}
setSelected((current) => {
const stillValid = normalizedOptions.some(
(option) => option.value === current && !option.disabled
);
if (stillValid) {
return current;
}
return normalizedOptions.find((option) => !option.disabled)?.value ?? "";
});
}, [normalizedOptions, value]);
const handleSelect = (nextValue: string) => {
const option = normalizedOptions.find((item) => item.value === nextValue);
if (!option || option.disabled || disabled) {
return;
}
if (nextValue === resolvedSelected) {
return;
}
setSelected(nextValue);
onChange?.(nextValue);
};
const focusOption = (index: number) => {
buttonRefs.current[index]?.focus();
};
const moveSelection = (index: number) => {
const option = normalizedOptions[index];
if (!option || option.disabled) {
return;
}
handleSelect(option.value);
focusOption(index);
};
const moveByDirection = (index: number, direction: 1 | -1) => {
const nextIndex = getEnabledOptionIndex(
normalizedOptions,
index,
direction
);
if (nextIndex === -1) {
return;
}
moveSelection(nextIndex);
};
const handleKeyDown = (
event: React.KeyboardEvent<HTMLButtonElement>,
index: number
) => {
const rtl = isRtlElement(groupRef.current);
const horizontalNext = rtl ? -1 : 1;
const horizontalPrevious = rtl ? 1 : -1;
switch (event.key) {
case "ArrowRight": {
event.preventDefault();
moveByDirection(
index,
orientation === "horizontal" ? horizontalNext : 1
);
break;
}
case "ArrowLeft": {
event.preventDefault();
moveByDirection(
index,
orientation === "horizontal" ? horizontalPrevious : -1
);
break;
}
case "ArrowDown": {
event.preventDefault();
moveByDirection(index, orientation === "vertical" ? 1 : horizontalNext);
break;
}
case "ArrowUp": {
event.preventDefault();
moveByDirection(
index,
orientation === "vertical" ? -1 : horizontalPrevious
);
break;
}
case "Home": {
event.preventDefault();
moveSelection(getEnabledOptionIndex(normalizedOptions, -1, 1));
break;
}
case "End": {
event.preventDefault();
moveSelection(getEnabledOptionIndex(normalizedOptions, 0, -1));
break;
}
default:
break;
}
};
if (normalizedOptions.length === 0) {
return null;
}
return (
<div
aria-disabled={disabled || undefined}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
aria-orientation={orientation}
className={cn(
componentThemeClassName,
"relative inline-flex items-stretch rounded-lg border border-border bg-background",
orientation === "vertical" ? "flex-col" : "flex-row",
segmentedShellSizeStyles[size][orientation],
disabled && "pointer-events-none opacity-60",
className
)}
ref={groupRef}
role="radiogroup"
>
{name && !disabled ? (
<input name={name} type="hidden" value={resolvedSelected} />
) : null}
<LayoutGroup id={resolvedLayoutId}>
{normalizedOptions.map((option, index) => {
const isSelected = resolvedSelected === option.value;
const showHover = isHovered === option.value && !isSelected;
const isOptionDisabled = disabled || option.disabled;
return (
<motion.button
aria-checked={isSelected}
aria-disabled={isOptionDisabled || undefined}
className={cn(
"relative z-10 inline-flex cursor-pointer touch-manipulation select-none items-center justify-center whitespace-nowrap rounded-md py-0 font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
segmentedItemSizeStyles[size],
isSelected ? "text-foreground" : "text-muted-foreground",
isOptionDisabled && "cursor-not-allowed opacity-50"
)}
disabled={isOptionDisabled}
key={option.value}
onClick={() => handleSelect(option.value)}
onKeyDown={(event) => handleKeyDown(event, index)}
onPointerEnter={() => {
if (!isOptionDisabled) {
setIsHovered(option.value);
}
}}
onPointerLeave={() =>
setIsHovered((current) =>
current === option.value ? null : current
)
}
ref={(node) => {
buttonRefs.current[index] = node;
}}
role="radio"
tabIndex={isSelected && !isOptionDisabled ? 0 : -1}
type="button"
whileTap={isOptionDisabled ? undefined : { scale: 0.96 }}
>
{isSelected ? (
<motion.span
aria-hidden
className="absolute inset-0 z-0 rounded-md bg-accent will-change-[transform,width,height]"
initial={false}
layoutId={resolvedLayoutId}
transition={segmentedIndicatorTransition}
/>
) : null}
<motion.span
animate={{
opacity: showHover ? 1 : 0,
}}
aria-hidden
className="pointer-events-none absolute inset-0 z-0 rounded-md bg-muted/40"
initial={false}
transition={{
duration: 0.24,
ease: [0.22, 1, 0.36, 1],
}}
/>
<motion.span
animate={{
opacity: isSelected ? 1 : 0.72,
}}
className="relative z-10 inline-flex items-center gap-1.5"
transition={{
duration: 0.28,
ease: [0.22, 1, 0.36, 1],
}}
>
{option.icon}
{option.label}
</motion.span>
</motion.button>
);
})}
</LayoutGroup>
</div>
);
}
export {
Button,
IconButton,
ButtonGroup,
ButtonGroupItems,
ButtonGroupSeparator,
ButtonGroupText,
SegmentedControl,
actionButtonVariants,
buttonGroupVariants,
};
export type {
ButtonGroupSize,
ButtonVariant,
ButtonProps,
IconButtonProps,
ButtonGroupProps,
ButtonGroupItemsProps,
SegmentedControlProps,
SegmentedControlOption,
SegmentedControlOptionObject,
};
lib/utils.ts
import type { ClassValue } from "clsx";
import { clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
demo.tsx
export { default as default } from "@/import-examples/button-group";CSS
/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */
@layer properties;
@layer theme, base, components, utilities;
@layer theme {
:root, :host {
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
"Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
"Segoe UI Symbol", "Noto Color Emoji";
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
"Courier New", monospace;
--spacing: 0.25rem;
--text-xs: 0.75rem;
--text-xs--line-height: calc(1 / 0.75);
--text-sm: 0.875rem;
--text-sm--line-height: calc(1.25 / 0.875);
--font-weight-medium: 500;
--default-transition-duration: 150ms;
--default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
--default-font-family: var(--font-sans);
--default-mono-font-family: var(--font-mono);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-primary: var(--primary);
--color-secondary: var(--secondary);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-destructive: var(--destructive);
--color-popover-foreground: var(--popover-foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
}
}
@layer base {
*, ::after, ::before, ::backdrop, ::file-selector-button {
box-sizing: border-box;
margin: 0;
padding: 0;
border: 0 solid;
}
html, :host {
line-height: 1.5;
-webkit-text-size-adjust: 100%;
tab-size: 4;
font-family: var(--default-font-family, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");
font-feature-settings: var(--default-font-feature-settings, normal);
font-variation-settings: var(--default-font-variation-settings, normal);
-webkit-tap-highlight-color: transparent;
}
hr {
height: 0;
color: inherit;
border-top-width: 1px;
}
abbr:where([title]) {
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
}
h1, h2, h3, h4, h5, h6 {
font-size: inherit;
font-weight: inherit;
}
a {
color: inherit;
-webkit-text-decoration: inherit;
text-decoration: inherit;
}
b, strong {
font-weight: bolder;
}
code, kbd, samp, pre {
font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);
font-feature-settings: var(--default-mono-font-feature-settings, normal);
font-variation-settings: var(--default-mono-font-variation-settings, normal);
font-size: 1em;
}
small {
font-size: 80%;
}
sub, sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
table {
text-indent: 0;
border-color: inherit;
border-collapse: collapse;
}
:-moz-focusring:where(:not(iframe)) {
outline: auto;
}
progress {
vertical-align: baseline;
}
summary {
display: list-item;
}
ol, ul, menu {
list-style: none;
}
img, svg, video, canvas, audio, iframe, embed, object {
display: block;
vertical-align: middle;
}
img, video {
max-width: 100%;
height: auto;
}
button, input, select, optgroup, textarea, ::file-selector-button {
font: inherit;
font-feature-settings: inherit;
font-variation-settings: inherit;
letter-spacing: inherit;
color: inherit;
border-radius: 0;
background-color: transparent;
opacity: 1;
}
:where(select:is([multiple], [size])) optgroup {
font-weight: bolder;
}
:where(select:is([multiple], [size])) optgroup option {
padding-inline-start: 20px;
}
::file-selector-button {
margin-inline-end: 4px;
}
::placeholder {
opacity: 1;
}
@supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) {
::placeholder {
color: currentcolor;
@supports (color: color-mix(in lab, red, red)) {
color: color-mix(in oklab, currentcolor 50%, transparent);
}
}
}
textarea {
resize: vertical;
}
::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-date-and-time-value {
min-height: 1lh;
text-align: inherit;
}
::-webkit-datetime-edit {
display: inline-flex;
}
::-webkit-datetime-edit-fields-wrapper {
padding: 0;
}
::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field {
padding-block: 0;
}
::-webkit-calendar-picker-indicator {
line-height: 1;
}
:-moz-ui-invalid {
box-shadow: none;
}
button, input:where([type="button"], [type="reset"], [type="submit"]), ::file-selector-button {
appearance: button;
}
::-webkit-inner-spin-button, ::-webkit-outer-spin-button {
height: auto;
}
[hidden]:where(:not([hidden="until-found"])) {
display: none !important;
}
}
@layer utilities {
.pointer-events-none {
pointer-events: none;
}
.absolute {
position: absolute;
}
.relative {
position: relative;
}
.inset-0 {
inset: 0px;
}
.top-0 {
top: 0px;
}
.left-0 {
left: 0px;
}
.z-0 {
z-index: 0;
}
.z-10 {
z-index: 10;
}
.flex {
display: flex;
}
.hidden {
display: none;
}
.inline-flex {
display: inline-flex;
}
.size-8 {
width: calc(var(--spacing) * 8);
height: calc(var(--spacing) * 8);
}
.size-9 {
width: calc(var(--spacing) * 9);
height: calc(var(--spacing) * 9);
}
.size-10 {
width: calc(var(--spacing) * 10);
height: calc(var(--spacing) * 10);
}
.h-full {
height: 100%;
}
.h-px {
height: 1px;
}
.min-h-7 {
min-height: calc(var(--spacing) * 7);
}
.min-h-8 {
min-height: calc(var(--spacing) * 8);
}
.min-h-9 {
min-height: calc(var(--spacing) * 9);
}
.min-h-10 {
min-height: calc(var(--spacing) * 10);
}
.w-fit {
width: fit-content;
}
.w-full {
width: 100%;
}
.w-px {
width: 1px;
}
.min-w-8 {
min-width: calc(var(--spacing) * 8);
}
.min-w-9 {
min-width: calc(var(--spacing) * 9);
}
.min-w-10 {
min-width: calc(var(--spacing) * 10);
}
.flex-1 {
flex: 1;
}
.shrink-0 {
flex-shrink: 0;
}
.-translate-x-1\/2 {
--tw-translate-x: calc(calc(1 / 2 * 100%) * -1);
translate: var(--tw-translate-x) var(--tw-translate-y);
}
.-translate-y-1\/2 {
--tw-translate-y: calc(calc(1 / 2 * 100%) * -1);
translate: var(--tw-translate-x) var(--tw-translate-y);
}
.cursor-not-allowed {
cursor: not-allowed;
}
.cursor-pointer {
cursor: pointer;
}
.touch-manipulation {
touch-action: manipulation;
}
.resize {
resize: both;
}
.flex-col {
flex-direction: column;
}
.flex-row {
flex-direction: row;
}
.items-center {
align-items: center;
}
.items-stretch {
align-items: stretch;
}
.justify-center {
justify-content: center;
}
.gap-0\.5 {
gap: calc(var(--spacing) * 0.5);
}
.gap-1 {
gap: var(--spacing);
}
.gap-1\.5 {
gap: calc(var(--spacing) * 1.5);
}
.gap-2 {
gap: calc(var(--spacing) * 2);
}
.self-stretch {
align-self: stretch;
}
.overflow-hidden {
overflow: hidden;
}
.rounded-\[inherit\] {
border-radius: inherit;
}
.rounded-full {
border-radius: calc(infinity * 1px);
}
.rounded-lg {
border-radius: var(--radius);
}
.rounded-md {
border-radius: calc(var(--radius) - 2px);
}
.rounded-none {
border-radius: 0;
}
.rounded-l-\[7px\] {
border-top-left-radius: 7px;
border-bottom-left-radius: 7px;
}
.rounded-r-\[7px\] {
border-top-right-radius: 7px;
border-bottom-right-radius: 7px;
}
.border {
border-style: var(--tw-border-style);
border-width: 1px;
}
.border-0 {
border-style: var(--tw-border-style);
border-width: 0px;
}
.border-r {
border-right-style: var(--tw-border-style);
border-right-width: 1px;
}
.border-border {
border-color: var(--border);
}
.bg-accent {
background-color: var(--accent);
}
.bg-background {
background-color: var(--background);
}
.bg-border {
background-color: var(--border);
}
.bg-current {
background-color: currentcolor;
}
.bg-input {
background-color: var(--input);
}
.bg-muted {
background-color: var(--muted);
}
.bg-muted\/40 {
background-color: var(--muted);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--muted) 40%, transparent);
}
}
.bg-muted\/45 {
background-color: var(--muted);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--muted) 45%, transparent);
}
}
.bg-transparent {
background-color: transparent;
}
.p-0\.5 {
padding: calc(var(--spacing) * 0.5);
}
.p-1 {
padding: var(--spacing);
}
.px-2 {
padding-inline: calc(var(--spacing) * 2);
}
.px-2\.5 {
padding-inline: calc(var(--spacing) * 2.5);
}
.px-3 {
padding-inline: calc(var(--spacing) * 3);
}
.px-4 {
padding-inline: calc(var(--spacing) * 4);
}
.py-0 {
padding-block: 0px;
}
.text-sm {
font-size: var(--text-sm);
line-height: var(--tw-leading, var(--text-sm--line-height));
}
.text-xs {
font-size: var(--text-xs);
line-height: var(--tw-leading, var(--text-xs--line-height));
}
.font-medium {
--tw-font-weight: var(--font-weight-medium);
font-weight: var(--font-weight-medium);
}
.whitespace-nowrap {
white-space: nowrap;
}
.text-destructive {
color: var(--destructive);
}
.text-foreground {
color: var(--foreground);
}
.text-muted-foreground {
color: var(--muted-foreground);
}
.opacity-50 {
opacity: 50%;
}
.opacity-60 {
opacity: 60%;
}
.outline {
outline-style: var(--tw-outline-style);
outline-width: 1px;
}
.transition {
transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, content-visibility, overlay, pointer-events;
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
transition-duration: var(--tw-duration, var(--default-transition-duration));
}
.transition-colors {
transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to;
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
transition-duration: var(--tw-duration, var(--default-transition-duration));
}
.will-change-\[transform\,width\,height\] {
will-change: transform,width,height;
}
.outline-none {
--tw-outline-style: none;
outline-style: none;
}
.select-none {
-webkit-user-select: none;
user-select: none;
}
.\[--color-accent-foreground\:var\(--ic-accent-foreground\)\] {
--color-accent-foreground: var(--ic-accent-foreground);
}
.\[--color-accent\:var\(--ic-accent\)\] {
--color-accent: var(--ic-accent);
}
.\[--color-background\:var\(--ic-background\)\] {
--color-background: var(--ic-background);
}
.\[--color-border\:var\(--ic-border\)\] {
--color-border: var(--ic-border);
}
.\[--color-brand-soft\:var\(--ic-brand-soft\)\] {
--color-brand-soft: var(--ic-brand-soft);
}
.\[--color-brand\:var\(--ic-brand\)\] {
--color-brand: var(--ic-brand);
}
.\[--color-card-foreground\:var\(--ic-card-foreground\)\] {
--color-card-foreground: var(--ic-card-foreground);
}
.\[--color-card\:var\(--ic-card\)\] {
--color-card: var(--ic-card);
}
.\[--color-chart-1\:var\(--ic-chart-1\)\] {
--color-chart-1: var(--ic-chart-1);
}
.\[--color-chart-2\:var\(--ic-chart-2\)\] {
--color-chart-2: var(--ic-chart-2);
}
.\[--color-chart-3\:var\(--ic-chart-3\)\] {
--color-chart-3: var(--ic-chart-3);
}
.\[--color-chart-4\:var\(--ic-chart-4\)\] {
--color-chart-4: var(--ic-chart-4);
}
.\[--color-chart-5\:var\(--ic-chart-5\)\] {
--color-chart-5: var(--ic-chart-5);
}
.\[--color-destructive\:var\(--ic-destructive\)\] {
--color-destructive: var(--ic-destructive);
}
.\[--color-foreground\:var\(--ic-foreground\)\] {
--color-foreground: var(--ic-foreground);
}
.\[--color-input\:var\(--ic-input\)\] {
--color-input: var(--ic-input);
}
.\[--color-muted-foreground\:var\(--ic-muted-foreground\)\] {
--color-muted-foreground: var(--ic-muted-foreground);
}
.\[--color-muted\:var\(--ic-muted\)\] {
--color-muted: var(--ic-muted);
}
.\[--color-paper\:var\(--ic-paper\)\] {
--color-paper: var(--ic-paper);
}
.\[--color-popover-foreground\:var\(--ic-popover-foreground\)\] {
--color-popover-foreground: var(--ic-popover-foreground);
}
.\[--color-primary\:var\(--ic-primary\)\] {
--color-primary: var(--ic-primary);
}
.\[--color-ring\:var\(--ic-ring\)\] {
--color-ring: var(--ic-ring);
}
.\[--color-secondary\:var\(--ic-secondary\)\] {
--color-secondary: var(--ic-secondary);
}
.\[--ic-accent-foreground\:\#111111\] {
--ic-accent-foreground: #111111;
}
.\[--ic-accent\:\#f3f5f8\] {
--ic-accent: #f3f5f8;
}
.\[--ic-background\:\#ffffff\] {
--ic-background: #ffffff;
}
.\[--ic-border\:\#e3e7ec\] {
--ic-border: #e3e7ec;
}
.\[--ic-brand-soft\:\#bae6fd\] {
--ic-brand-soft: #bae6fd;
}
.\[--ic-brand\:\#0ea5e9\] {
--ic-brand: #0ea5e9;
}
.\[--ic-card-foreground\:\#111111\] {
--ic-card-foreground: #111111;
}
.\[--ic-card\:\#ffffff\] {
--ic-card: #ffffff;
}
.\[--ic-chart-1\:oklch\(0\.52_0\.19_254\)\] {
--ic-chart-1: oklch(0.52 0.19 254);
}
.\[--ic-chart-2\:oklch\(0\.74_0\.11_232\)\] {
--ic-chart-2: oklch(0.74 0.11 232);
}
.\[--ic-chart-3\:oklch\(0\.42_0\.16_262\)\] {
--ic-chart-3: oklch(0.42 0.16 262);
}
.\[--ic-chart-4\:oklch\(0\.84_0\.07_228\)\] {
--ic-chart-4: oklch(0.84 0.07 228);
}
.\[--ic-chart-5\:oklch\(0\.62_0\.14_240\)\] {
--ic-chart-5: oklch(0.62 0.14 240);
}
.\[--ic-destructive\:\#dc2626\] {
--ic-destructive: #dc2626;
}
.\[--ic-foreground\:\#111111\] {
--ic-foreground: #111111;
}
.\[--ic-input\:\#e3e7ec\] {
--ic-input: #e3e7ec;
}
.\[--ic-muted-foreground\:\#6d7480\] {
--ic-muted-foreground: #6d7480;
}
.\[--ic-muted\:\#f5f7fa\] {
--ic-muted: #f5f7fa;
}
.\[--ic-paper\:\#fcfcfd\] {
--ic-paper: #fcfcfd;
}
.\[--ic-popover-foreground\:\#111111\] {
--ic-popover-foreground: #111111;
}
.\[--ic-primary\:\#111111\] {
--ic-primary: #111111;
}
.\[--ic-ring\:rgba\(17\,17\,17\,0\.16\)\] {
--ic-ring: rgba(17,17,17,0.16);
}
.\[--ic-secondary\:\#646b75\] {
--ic-secondary: #646b75;
}
.\[--ic-shadow-soft\:0_18px_38px_-24px_rgba\(15\,23\,42\,0\.35\)\] {
--ic-shadow-soft: 0 18px 38px -24px rgba(15,23,42,0.35);
}
.\[--ic-surface-border\:\#e9edf2\] {
--ic-surface-border: #e9edf2;
}
@media (hover: hover) {
.hover\:bg-destructive\/10:hover {
background-color: var(--destructive);
}
@supports (color: color-mix(in lab, red, red)) {
.hover\:bg-destructive\/10:hover {
background-color: color-mix(in oklab, var(--destructive) 10%, transparent);
}
}
.hover\:bg-muted:hover {
background-color: var(--muted);
}
.hover\:text-destructive:hover {
color: var(--destructive);
}
.hover\:text-foreground:hover {
color: var(--foreground);
}
}
.focus-visible\:ring-2:focus-visible {
--tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
}
.focus-visible\:ring-ring:focus-visible {
--tw-ring-color: var(--ring);
}
.focus-visible\:ring-offset-2:focus-visible {
--tw-ring-offset-width: 2px;
--tw-ring-offset-shadow: var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
}
.focus-visible\:ring-inset:focus-visible {
--tw-ring-inset: inset;
}
:is(.\*\:focus-visible\:relative > *):focus-visible {
position: relative;
}
:is(.\*\:focus-visible\:z-10 > *):focus-visible {
z-index: 10;
}
.disabled\:pointer-events-none:disabled {
pointer-events: none;
}
.disabled\:opacity-50:disabled {
opacity: 50%;
}
.has-\[\>\[data-slot\=button-group-items\]\]\:gap-2:has( > [data-slot=button-group-items]) {
gap: calc(var(--spacing) * 2);
}
.data-horizontal\:mx-px[data-horizontal] {
margin-inline: 1px;
}
.data-horizontal\:w-auto[data-horizontal] {
width: auto;
}
.data-vertical\:my-px[data-vertical] {
margin-block: 1px;
}
.data-vertical\:h-auto[data-vertical] {
height: auto;
}
.dark\:\[--ic-accent-foreground\:\#f6f3ec\]:where(.dark, .dark *) {
--ic-accent-foreground: #f6f3ec;
}
.dark\:\[--ic-accent\:\#1a1a18\]:where(.dark, .dark *) {
--ic-accent: #1a1a18;
}
.dark\:\[--ic-background\:\#111111\]:where(.dark, .dark *) {
--ic-background: #111111;
}
.dark\:\[--ic-border\:\#2b2a25\]:where(.dark, .dark *) {
--ic-border: #2b2a25;
}
.dark\:\[--ic-brand-soft\:\#0c4a6e\]:where(.dark, .dark *) {
--ic-brand-soft: #0c4a6e;
}
.dark\:\[--ic-brand\:\#38bdf8\]:where(.dark, .dark *) {
--ic-brand: #38bdf8;
}
.dark\:\[--ic-card-foreground\:\#f6f3ec\]:where(.dark, .dark *) {
--ic-card-foreground: #f6f3ec;
}
.dark\:\[--ic-card\:\#111111\]:where(.dark, .dark *) {
--ic-card: #111111;
}
.dark\:\[--ic-chart-1\:oklch\(0\.68_0\.17_250\)\]:where(.dark, .dark *) {
--ic-chart-1: oklch(0.68 0.17 250);
}
.dark\:\[--ic-chart-2\:oklch\(0\.82_0\.09_225\)\]:where(.dark, .dark *) {
--ic-chart-2: oklch(0.82 0.09 225);
}
.dark\:\[--ic-chart-3\:oklch\(0\.58_0\.15_260\)\]:where(.dark, .dark *) {
--ic-chart-3: oklch(0.58 0.15 260);
}
.dark\:\[--ic-chart-4\:oklch\(0\.75_0\.12_235\)\]:where(.dark, .dark *) {
--ic-chart-4: oklch(0.75 0.12 235);
}
.dark\:\[--ic-chart-5\:oklch\(0\.88_0\.06_220\)\]:where(.dark, .dark *) {
--ic-chart-5: oklch(0.88 0.06 220);
}
.dark\:\[--ic-destructive\:\#f87171\]:where(.dark, .dark *) {
--ic-destructive: #f87171;
}
.dark\:\[--ic-foreground\:\#f6f3ec\]:where(.dark, .dark *) {
--ic-foreground: #f6f3ec;
}
.dark\:\[--ic-input\:\#2b2a25\]:where(.dark, .dark *) {
--ic-input: #2b2a25;
}
.dark\:\[--ic-muted-foreground\:\#9a958a\]:where(.dark, .dark *) {
--ic-muted-foreground: #9a958a;
}
.dark\:\[--ic-muted\:\#171716\]:where(.dark, .dark *) {
--ic-muted: #171716;
}
.dark\:\[--ic-paper\:\#171716\]:where(.dark, .dark *) {
--ic-paper: #171716;
}
.dark\:\[--ic-popover-foreground\:\#f6f3ec\]:where(.dark, .dark *) {
--ic-popover-foreground: #f6f3ec;
}
.dark\:\[--ic-primary\:\#f6f3ec\]:where(.dark, .dark *) {
--ic-primary: #f6f3ec;
}
.dark\:\[--ic-ring\:rgba\(246\,243\,236\,0\.18\)\]:where(.dark, .dark *) {
--ic-ring: rgba(246,243,236,0.18);
}
.dark\:\[--ic-secondary\:\#cbc6bb\]:where(.dark, .dark *) {
--ic-secondary: #cbc6bb;
}
.dark\:\[--ic-shadow-soft\:0_20px_44px_-28px_rgba\(0\,0\,0\,0\.6\)\]:where(.dark, .dark *) {
--ic-shadow-soft: 0 20px 44px -28px rgba(0,0,0,0.6);
}
.dark\:\[--ic-surface-border\:\#2a2a25\]:where(.dark, .dark *) {
--ic-surface-border: #2a2a25;
}
.\[\&_svg\]\:pointer-events-none svg {
pointer-events: none;
}
.\[\&_svg\]\:size-3\.5 svg {
width: calc(var(--spacing) * 3.5);
height: calc(var(--spacing) * 3.5);
}
.\[\&_svg\]\:size-4 svg {
width: calc(var(--spacing) * 4);
height: calc(var(--spacing) * 4);
}
.\[\&_svg\]\:shrink-0 svg {
flex-shrink: 0;
}
.\[\&_svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-4 svg:not([class*='size-']) {
width: calc(var(--spacing) * 4);
height: calc(var(--spacing) * 4);
}
.has-\[select\[aria-hidden\=true\]\:last-child\]\:\[\&\>\[data-slot\=select-trigger\]\:last-of-type\]\:rounded-r-lg:has(:is(select[aria-hidden=true]:last-child)) > [data-slot=select-trigger]:last-of-type {
border-top-right-radius: var(--radius);
border-bottom-right-radius: var(--radius);
}
.\[\&\>\[data-slot\=select-trigger\]\:not\(\[class\*\=\'w-\'\]\)\]\:w-fit > [data-slot=select-trigger]:not([class*='w-']) {
width: fit-content;
}
.\[\&\>\[data-slot\]\]\:rounded-none > [data-slot] {
border-radius: 0;
}
.\[\&\>\[data-slot\]\~\[data-slot\]\]\:border-t > [data-slot] ~ [data-slot] {
border-top-style: var(--tw-border-style);
border-top-width: 1px;
}
.\[\&\>\[data-slot\]\~\[data-slot\]\]\:border-l > [data-slot] ~ [data-slot] {
border-left-style: var(--tw-border-style);
border-left-width: 1px;
}
.\[\&\>\[data-slot\]\~\[data-slot\]\]\:border-border > [data-slot] ~ [data-slot] {
border-color: var(--border);
}
.\[\&\>input\]\:flex-1 > input {
flex: 1;
}
}
:root {
--background: #fff;
--foreground: #111;
--primary: #171717;
--primary-foreground: #fff;
--secondary: #eee;
--secondary-foreground: #171717;
--muted: #eee;
--muted-foreground: #666;
--accent: #eee;
--accent-foreground: #171717;
--border: #ddd;
--input: #ddd;
--ring: #777;
--destructive: #dc2626;
--destructive-foreground: #fff;
--popover: #fff;
--popover-foreground: #111;
--success: #15803d;
--success-foreground: #fff;
--radius: .625rem;
--hairline: 1px;
}
.dark {
--background: #090909;
--foreground: #fafafa;
--primary: #fafafa;
--primary-foreground: #171717;
--secondary: #262626;
--secondary-foreground: #fafafa;
--muted: #262626;
--muted-foreground: #a3a3a3;
--accent: #262626;
--accent-foreground: #fafafa;
--border: #333;
--input: #333;
--popover: #171717;
--popover-foreground: #fafafa;
}
:root {
--header-height-mobile: 3.5rem;
--header-height-desktop: 3.5rem;
--nav-stack-height-mobile: var(--header-height-mobile);
--nav-stack-height-desktop: var(--header-height-desktop);
--background: #ffffff;
--foreground: #111111;
--primary: #111111;
--primary-foreground: #ffffff;
--secondary: #646b75;
--secondary-foreground: #ffffff;
--surface-border: #e9edf2;
--border: #e3e7ec;
--card: #ffffff;
--card-foreground: #111111;
--muted: #f5f7fa;
--muted-foreground: #6d7480;
--accent: #f3f5f8;
--accent-foreground: #111111;
--input: #e3e7ec;
--ring: rgba(17, 17, 17, 0.16);
--destructive: #dc2626;
--accent-pro: #0ea5e9;
--sidebar-accent: #f5f7fa;
--sidebar-accent-foreground: #111111;
--separator-dash: rgb(227 231 236 / 1);
--selection-bg: #cfeeff;
--selection-fg: #0f172a;
--nav-menu-surface: #f4f4f5;
--nav-menu-item-hover: #e4e4e7;
--shadow-card: inset 0 1px 0 rgb(255 255 255 / 88%), inset 0 -1px 0 rgb(0 0 0 / 5%), 0 1px
1px rgb(0 0 0 / 3%), 0 8px 24px rgb(0 0 0 / 4%);
--radius: 0.4rem;
}
.dark {
--background: #111111;
--foreground: #f6f3ec;
--secondary-bg: #171716;
--secondary: #cbc6bb;
--secondary-foreground: #111111;
--primary: #f6f3ec;
--primary-foreground: #111111;
--surface-border: #2a2a25;
--border: #2b2a25;
--card: #111111;
--card-foreground: #f6f3ec;
--muted: #171716;
--muted-foreground: #9a958a;
--accent: #1a1a18;
--accent-foreground: #f6f3ec;
--input: #2b2a25;
--ring: rgba(246, 243, 236, 0.18);
--destructive: #f87171;
--accent-pro: #38bdf8;
--sidebar-accent: #262626;
--sidebar-accent-foreground: #fff;
--separator-dash: rgb(92 91 84 / 1);
--selection-bg: #cfeeff;
--selection-fg: #0f172a;
--nav-menu-surface: #1a1a1c;
--nav-menu-item-hover: #262628;
}
.dark .hero-brush-underline::after {
background: #16a34a;
}
@keyframes status-blink {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.28;
transform: scale(0.82);
}
}
@keyframes iconiq-accordion-down {
from {
height: 0;
}
to {
height: var(--radix-accordion-content-height);
}
}
@keyframes iconiq-accordion-up {
from {
height: var(--radix-accordion-content-height);
}
to {
height: 0;
}
}
.dark main .text-neutral-500 {
color: rgb(163 163 163);
}
.dark main .text-neutral-600 {
color: rgb(212 212 212);
}
.dark main .text-neutral-700 {
color: rgb(230 230 230);
}
.dark main .text-neutral-800 {
color: rgb(245 245 245);
}
.dark main .text-neutral-900 {
color: rgb(255 255 255);
}
.dark main .text-neutral-400 {
color: rgb(163 163 163);
}
.dark main code {
color: rgb(212 212 212);
}
.dark main code.rounded, .dark main code.rounded-md, .dark main code[class*="bg-neutral-200"], .dark main code[class*="bg-neutral-50"] {
background-color: rgb(38 38 38);
color: rgb(230 230 230);
}
.dark code.rounded, .dark code.rounded-md, .dark code[class*="bg-neutral-200"] {
background-color: rgb(38 38 38);
color: rgb(230 230 230);
}
.dark code[class*="bg-amber-200"] {
background-color: rgb(69 69 40);
color: rgb(253 253 230);
}
.dark main a.text-neutral-700 {
color: rgb(230 230 230);
}
.dark main a:hover {
color: rgb(255 255 255);
}
.dark html[data-route-split-docs] [data-code-block] .shiki, .dark html[data-route-split-docs] [data-code-block] .shiki span {
color: var(--shiki-dark) !important;
}
@property --tw-translate-x {
syntax: "*";
inherits: false;
initial-value: 0;
}
@property --tw-translate-y {
syntax: "*";
inherits: false;
initial-value: 0;
}
@property --tw-translate-z {
syntax: "*";
inherits: false;
initial-value: 0;
}
@property --tw-border-style {
syntax: "*";
inherits: false;
initial-value: solid;
}
@property --tw-font-weight {
syntax: "*";
inherits: false;
}
@property --tw-outline-style {
syntax: "*";
inherits: false;
initial-value: solid;
}
@property --tw-shadow {
syntax: "*";
inherits: false;
initial-value: 0 0 #0000;
}
@property --tw-shadow-color {
syntax: "*";
inherits: false;
}
@property --tw-shadow-alpha {
syntax: "<percentage>";
inherits: false;
initial-value: 100%;
}
@property --tw-inset-shadow {
syntax: "*";
inherits: false;
initial-value: 0 0 #0000;
}
@property --tw-inset-shadow-color {
syntax: "*";
inherits: false;
}
@property --tw-inset-shadow-alpha {
syntax: "<percentage>";
inherits: false;
initial-value: 100%;
}
@property --tw-ring-color {
syntax: "*";
inherits: false;
}
@property --tw-ring-shadow {
syntax: "*";
inherits: false;
initial-value: 0 0 #0000;
}
@property --tw-inset-ring-color {
syntax: "*";
inherits: false;
}
@property --tw-inset-ring-shadow {
syntax: "*";
inherits: false;
initial-value: 0 0 #0000;
}
@property --tw-ring-inset {
syntax: "*";
inherits: false;
}
@property --tw-ring-offset-width {
syntax: "<length>";
inherits: false;
initial-value: 0px;
}
@property --tw-ring-offset-color {
syntax: "*";
inherits: false;
initial-value: #fff;
}
@property --tw-ring-offset-shadow {
syntax: "*";
inherits: false;
initial-value: 0 0 #0000;
}
@layer properties {
@supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {
*, ::before, ::after, ::backdrop {
--tw-translate-x: 0;
--tw-translate-y: 0;
--tw-translate-z: 0;
--tw-border-style: solid;
--tw-font-weight: initial;
--tw-outline-style: solid;
--tw-shadow: 0 0 #0000;
--tw-shadow-color: initial;
--tw-shadow-alpha: 100%;
--tw-inset-shadow: 0 0 #0000;
--tw-inset-shadow-color: initial;
--tw-inset-shadow-alpha: 100%;
--tw-ring-color: initial;
--tw-ring-shadow: 0 0 #0000;
--tw-inset-ring-color: initial;
--tw-inset-ring-shadow: 0 0 #0000;
--tw-ring-inset: initial;
--tw-ring-offset-width: 0px;
--tw-ring-offset-color: #fff;
--tw-ring-offset-shadow: 0 0 #0000;
}
}
}