Toggle Group
Composable toolbar toggles: a standalone aria-pressed Toggle plus a shadcn-shaped ToggleGroup of ToggleGroupItem children in single or multiple mode, with default/outline variants, sm/md/lg sizes, one roving tab stop with arrow/Home/End keys, a pressed surface that glides between items in single mode and pops per item in multiple mode — the composable, icon-first counterpart to SegmentedControl's radiogroup.
Preview
Install
Add it with the shadcn CLI, or copy the source manually.
$ bunx --bun shadcn add @karaui/toggle-group
Needs the theme tokens once. Already ran
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
TSXlib/ease.ts
// 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;
TSXlib/touch.ts
// Shared touch primitives. iOS and iPadOS run their own gestures on top of the
// page — the long-press selection callout and the selection it drags in with
// it — and they win: once the platform claims a touch it cancels ours
// mid-gesture, so a press-and-hold or a drag simply dies. Surfaces that own
// their gesture have to opt out.
//
// What the two classes below cover, precisely:
// - `-webkit-touch-callout: none` stops iOS's long-press callout. WebKit-only:
// it is not a property other engines have, so it is inert everywhere else.
// - `user-select: none` stops the long-press selection on every engine,
// Android included, and stops a drag from painting a selection under the
// cursor. It is inherited, so it reaches every descendant — which is why the
// two classes differ only in whether they apply it unconditionally.
// What neither covers:
// - Chrome for Android's long-press menu on a link or an image. No CSS
// suppresses it; a gesture surface that wraps one needs its own
// `onContextMenu` with `preventDefault()`.
// - The native drag of an `<img>` or `<a>` descendant. `-webkit-user-drag` is
// not inherited and plain divs and buttons are not drag sources, so setting
// it on the surface does nothing — the child itself needs `draggable={false}`.
/**
* Classes for a surface that *is* the control: a thumb, a drum, a stage, a
* handle, a hold button. Selection is suppressed on every input, because a
* drag that highlights the control's own label is wrong on a mouse too.
* Compose with `touch-none` when the surface also owns the scroll axis — leave
* it off when the page must still scroll from there.
*/
export const TOUCH_GESTURE_CLASS = "select-none [-webkit-touch-callout:none]";
/**
* The same opt-out for a gesture surface that wraps content the consumer owns:
* a scroller, a context-menu trigger, a sheet header, a list row. Selection is
* suppressed only where the platform runs its own press gestures — a coarse
* pointer — so a mouse user can still select and copy that content. If the
* gesture itself would paint a selection under the cursor, add `select-none`
* for the duration of the gesture rather than reaching for
* `TOUCH_GESTURE_CLASS`.
*
* `pointer: coarse` describes the *primary* pointer and nothing else, so a
* hybrid machine reads it wrong in both directions: a tablet with a mouse
* plugged in keeps touch as primary and loses mouse selection, and a laptop
* with a touchscreen keeps the mouse as primary and leaves selection live
* under a finger. No media query can answer per interaction — the query is
* about the device, and the question is about the gesture in progress. The
* default stays here because it is right on the machines that are one thing or
* the other, and losing a selection is a nuisance; where the miss costs a
* *gesture* instead, the surface pairs it with `holdSelection` on the press.
*/
export const TOUCH_GESTURE_CONTENT_CLASS =
"[-webkit-touch-callout:none] pointer-coarse:select-none";
/**
* Grow a small control's *hit area* to at least 44×44px (WCAG 2.5.5, Apple
* HIG) on a coarse pointer, without changing what it looks like or the space
* it takes in layout. An invisible `::after` box, centered on the control and
* `max(100%, 2.75rem)` on each axis, catches the taps: a 32px icon button
* reaches 6px past each edge, a wide 40px button only grows vertically, and a
* control already 44px or larger is left as it is. Hit-testing a pseudo
* element lands on its owner, so handlers, focus and `:active` all behave.
*
* Mouse and trackpad rendering is untouched — nothing here applies outside
* `pointer: coarse`. Requirements on the element it goes on:
* - It must be the containing block: add `relative` (and keep it mergeable, so
* a consumer's `absolute` still wins through `cn`).
* - It must not clip itself (`overflow-hidden`), or the overhang is clipped
* away along with its hit area.
* - It must render children: `<input>` and other void elements have no
* `::after`. Put it on the wrapper or label instead.
* - It must not already use `::after` for something visible.
*
* Centered 44px means two 32px buttons with a 4px gap overlap by 8px; the
* later one wins that strip, which still ends well short of the earlier
* button's visual center. Below a ~32px pitch the later neighbour starts
* eating into that center — use `TOUCH_TARGET_Y_CLASS` for such dense rows.
*
* The overhang is real geometry: inside a scroll container it counts as
* scrollable overflow, so a control flush against a scroller's edge can add a
* few pixels of scroll on touch screens. Give such a scroller a little padding
* on that side.
*/
export const TOUCH_TARGET_CLASS =
"pointer-coarse:after:absolute pointer-coarse:after:top-1/2 pointer-coarse:after:left-1/2 pointer-coarse:after:size-[max(100%,2.75rem)] pointer-coarse:after:-translate-x-1/2 pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']";
/**
* `TOUCH_TARGET_CLASS` on the vertical axis only, for controls packed tighter
* than ~32px apart in a row (pagination dots, dense swatches), where growing
* sideways would hand most of each control to its neighbour. Same rules.
*/
export const TOUCH_TARGET_Y_CLASS =
"pointer-coarse:after:absolute pointer-coarse:after:inset-x-0 pointer-coarse:after:top-1/2 pointer-coarse:after:h-[max(100%,2.75rem)] pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']";
/**
* Suppress selection on `element` for as long as a gesture is running on it,
* whatever the primary pointer of the machine happens to be. Returns the
* release. Inline, so it wins over the class above and is gone again the
* moment the gesture ends.
*
* For the press gestures a native selection would otherwise steal — a
* long-press that opens a menu. Elsewhere prefer the classes: a surface that
* takes selection away for the whole session is a surface whose text nobody
* can copy.
*/
export function holdSelection(element: HTMLElement) {
element.style.setProperty("user-select", "none");
element.style.setProperty("-webkit-user-select", "none");
return () => {
element.style.removeProperty("user-select");
element.style.removeProperty("-webkit-user-select");
};
}
/**
* Pointer capture, best effort. WebKit throws `NotFoundError` when the pointer
* is already gone by the time the handler runs — routine on iOS, where the
* system can claim the touch first — and an uncaught throw takes the rest of
* the handler, the gesture included, down with it. Touch pointers carry
* implicit capture anyway, so losing it is never fatal.
*/
export function capturePointer(element: Element, pointerId: number) {
try {
element.setPointerCapture(pointerId);
} catch {
// Pointer is no longer active — implicit capture still applies on touch.
}
}
/** Release a capture taken with `capturePointer`, ignoring a stale pointer. */
export function releasePointer(element: Element, pointerId: number) {
try {
if (element.hasPointerCapture(pointerId)) {
element.releasePointerCapture(pointerId);
}
} catch {
// Capture was already dropped by the browser.
}
}
/**
* Whether this event came from a pointer that is *hovering*: not a touch, and
* not currently pressed. Which input the user is holding right now is not
* something a device capability can answer — a touchscreen laptop hovers and
* taps, and iPadOS reports a fine hovering pointer for a finger — so both
* paths stay live and each handler branches on the event it was given.
*
* A pen resting on the glass is making contact, not hovering: `buttons` is the
* tell, and it sends a pen tap down the same route a finger takes.
*
* This answers what an *enter* asks. A leave is the other half of a pair and
* has to be read against the enter that started it — `useHoverGesture` in
* `lib/hooks/use-hover-gesture` does that, and hover surfaces should use it
* rather than asking this question twice.
*/
export const isHoveringPointer = (event: {
pointerType: string;
buttons: number;
}) => event.pointerType !== "touch" && event.buttons === 0;
TSXlib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
TSXcomponents/motion/toggle-group.tsx
"use client";
// karaui.kamisama-v0.my.id/components/motion/toggle-group
import {
AnimatePresence,
type HTMLMotionProps,
motion,
useReducedMotion,
} from "motion/react";
import {
createContext,
forwardRef,
type KeyboardEvent,
type ReactNode,
useCallback,
useContext,
useEffect,
useId,
useRef,
useState,
} from "react";
import { SPRING_LAYOUT, SPRING_PRESS, SPRING_SWAP } from "@/lib/ease";
import { TOUCH_TARGET_CLASS } from "@/lib/touch";
import { cn } from "@/lib/utils";
// Toggle / ToggleGroup vs SegmentedControl: `SegmentedControl`
// (segmented-control.tsx) is a radiogroup form control that always holds one
// value and ships a data-driven `ToggleGroup` for quick `items` arrays. This
// file is the composable, shadcn-shaped toolbar primitive: a standalone
// `Toggle`, and a `ToggleGroup` you fill with `ToggleGroupItem` children
// (icons, labels, tooltips, anything), with `default`/`outline` variants. In
// single mode one pressed surface glides between items; in multiple mode each
// item pops its own surface in and out.
export type ToggleVariant = "default" | "outline";
export type ToggleSize = "sm" | "md" | "lg";
const sizeClass: Record<ToggleSize, string> = {
sm: "h-7 min-w-7 gap-1.5 px-2 text-xs [&_svg]:size-3.5",
md: "h-9 min-w-9 gap-2 px-2.5 text-sm [&_svg]:size-4",
lg: "h-11 min-w-11 gap-2 px-3.5 text-sm [&_svg]:size-[18px]",
};
const surfaceClass: Record<ToggleVariant, string> = {
default: "bg-muted ring-1 ring-border-strong",
outline: "bg-muted",
};
const itemBase =
"relative isolate inline-flex shrink-0 select-none items-center justify-center whitespace-nowrap font-medium outline-none transition-colors duration-200 focus-visible:z-10 focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50";
function useControllable<T>(
value: T | undefined,
defaultValue: T,
onChange: ((next: T) => void) | undefined,
) {
const [internal, setInternal] = useState<T>(defaultValue);
const controlled = value !== undefined;
const current = controlled ? value : internal;
const set = useCallback(
(next: T) => {
if (!controlled) setInternal(next);
onChange?.(next);
},
[controlled, onChange],
);
return [current, set] as const;
}
type ButtonBaseProps = Omit<HTMLMotionProps<"button">, "children" | "value" | "type">;
/** The pressed surface that pops in and out behind a standalone toggle or a multiple-mode item. */
function PopSurface({ show, variant }: { show: boolean; variant: ToggleVariant }) {
const reduce = useReducedMotion();
return (
<AnimatePresence initial={false}>
{show ? (
<motion.span
key="on"
aria-hidden
initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6 }}
animate={{ opacity: 1, scale: 1 }}
exit={
reduce
? { opacity: 0 }
: { opacity: 0, scale: 0.75, transition: { duration: 0.12 } }
}
transition={SPRING_SWAP}
className={cn("absolute inset-0 -z-10 rounded-[inherit]", surfaceClass[variant])}
/>
) : null}
</AnimatePresence>
);
}
/* ------------------------------------------------------------------------ */
/* Toggle */
/* ------------------------------------------------------------------------ */
export interface ToggleProps extends ButtonBaseProps {
pressed?: boolean;
defaultPressed?: boolean;
onPressedChange?: (pressed: boolean) => void;
variant?: ToggleVariant;
size?: ToggleSize;
children?: ReactNode;
}
/** A two-state button (`aria-pressed`) whose pressed surface springs in and out. */
export const Toggle = forwardRef<HTMLButtonElement, ToggleProps>(function Toggle(
{
pressed,
defaultPressed = false,
onPressedChange,
variant = "default",
size = "md",
disabled,
className,
children,
onClick,
...rest
},
ref,
) {
const reduce = useReducedMotion();
const [on, setOn] = useControllable(pressed, defaultPressed, onPressedChange);
return (
<motion.button
ref={ref}
type="button"
aria-pressed={on}
data-state={on ? "on" : "off"}
disabled={disabled}
whileTap={reduce || disabled ? undefined : { scale: 0.9 }}
transition={SPRING_PRESS}
onClick={(event) => {
onClick?.(event);
if (!event.defaultPrevented) setOn(!on);
}}
className={cn(
itemBase,
TOUCH_TARGET_CLASS,
"rounded-lg focus-visible:ring-offset-1 focus-visible:ring-offset-background",
sizeClass[size],
variant === "outline" && "border border-border",
on
? "text-foreground"
: "text-muted-foreground enabled:hover:bg-muted/60 enabled:hover:text-foreground",
className,
)}
{...rest}
>
<PopSurface show={on} variant={variant} />
{children}
</motion.button>
);
});
/* ------------------------------------------------------------------------ */
/* ToggleGroup */
/* ------------------------------------------------------------------------ */
interface ToggleGroupContextValue {
type: "single" | "multiple";
pressed: string[];
toggle: (value: string) => void;
variant: ToggleVariant;
size: ToggleSize;
disabled: boolean;
layoutId: string;
tabStop: string | null;
setTabStop: (value: string) => void;
}
const ToggleGroupContext = createContext<ToggleGroupContextValue | null>(null);
type ToggleGroupBase = Omit<
HTMLMotionProps<"div">,
"children" | "defaultValue" | "onChange" | "dir"
> & {
variant?: ToggleVariant;
size?: ToggleSize;
disabled?: boolean;
/** Arrow-key axis for roving focus. Default "horizontal" (both axes still work). */
orientation?: "horizontal" | "vertical";
children?: ReactNode;
};
export type ToggleGroupSingleProps = ToggleGroupBase & {
type: "single";
/** The pressed item, or `""` when none is pressed. */
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
/** Disallow un-pressing the active item, so one is always pressed. */
required?: boolean;
};
export type ToggleGroupMultipleProps = ToggleGroupBase & {
type: "multiple";
value?: string[];
defaultValue?: string[];
onValueChange?: (value: string[]) => void;
};
export type ToggleGroupProps = ToggleGroupSingleProps | ToggleGroupMultipleProps;
const ITEM_SELECTOR = "[data-toggle-group-item]";
function enabledItems(root: HTMLElement | null) {
if (!root) return [];
return Array.from(root.querySelectorAll<HTMLButtonElement>(ITEM_SELECTOR)).filter(
(el) => !el.disabled && el.closest("[data-toggle-group]") === root,
);
}
/**
* Toolbar-style group of `ToggleGroupItem`s. `type="single"` behaves like a
* set of mutually exclusive toggles (deselectable unless `required`) whose
* pressed surface glides between items; `type="multiple"` presses any subset.
* One tab stop, arrow keys / Home / End rove between items, Space/Enter press.
*/
export function ToggleGroup(props: ToggleGroupProps) {
const {
type,
value: _value,
defaultValue: _defaultValue,
onValueChange: _onValueChange,
variant = "default",
size = "md",
disabled = false,
orientation = "horizontal",
className,
children,
onKeyDown,
...rest
} = props;
// `required` only exists on the single variant; keep it off the DOM.
const { required: _required, ...domProps } = rest as typeof rest & { required?: boolean };
const single = props.type === "single";
const required = props.type === "single" && Boolean(props.required);
const [pressed, setPressed] = useControllable<string[]>(
props.value === undefined
? undefined
: props.type === "single"
? props.value
? [props.value]
: []
: props.value,
props.type === "single"
? props.defaultValue
? [props.defaultValue]
: []
: (props.defaultValue ?? []),
(next) => {
if (props.type === "single") props.onValueChange?.(next[0] ?? "");
else props.onValueChange?.(next);
},
);
const layoutId = useId();
const rootRef = useRef<HTMLDivElement>(null);
const [tabStop, setTabStop] = useState<string | null>(pressed[0] ?? null);
// Keep exactly one reachable tab stop: if the remembered item vanished or
// got disabled, fall back to the first pressed item, then the first enabled.
useEffect(() => {
const items = enabledItems(rootRef.current);
if (items.some((el) => el.dataset.value === tabStop)) return;
const fallback =
items.find((el) => pressed.includes(el.dataset.value ?? "")) ?? items[0];
const next = fallback?.dataset.value ?? null;
if (next !== tabStop) setTabStop(next);
});
const toggle = (itemValue: string) => {
const isOn = pressed.includes(itemValue);
if (single) {
if (isOn && required) return;
setPressed(isOn ? [] : [itemValue]);
} else {
setPressed(isOn ? pressed.filter((v) => v !== itemValue) : [...pressed, itemValue]);
}
};
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
onKeyDown?.(event);
if (event.defaultPrevented) return;
const items = enabledItems(rootRef.current);
const index = items.indexOf(document.activeElement as HTMLButtonElement);
if (index < 0) return;
const forward = orientation === "vertical" ? "ArrowDown" : "ArrowRight";
const backward = orientation === "vertical" ? "ArrowUp" : "ArrowLeft";
let next: number;
if (event.key === forward || event.key === "ArrowDown" || event.key === "ArrowRight")
next = (index + 1) % items.length;
else if (event.key === backward || event.key === "ArrowUp" || event.key === "ArrowLeft")
next = (index - 1 + items.length) % items.length;
else if (event.key === "Home") next = 0;
else if (event.key === "End") next = items.length - 1;
else return;
event.preventDefault();
// Focus moves; pressing stays on Space/Enter — these are toggles, not radios.
items[next].focus();
};
return (
<ToggleGroupContext.Provider
value={{
type,
pressed,
toggle,
variant,
size,
disabled,
layoutId,
tabStop,
setTabStop,
}}
>
{/* layoutRoot keeps the gliding surface's projection local to the group,
so scrolled or transformed ancestors never skew the glide. */}
<motion.div
ref={rootRef}
layoutRoot
role="group"
data-toggle-group=""
data-orientation={orientation}
aria-disabled={disabled || undefined}
onKeyDown={handleKeyDown}
className={cn(
"inline-flex w-fit items-center",
orientation === "vertical" && "flex-col items-stretch",
variant === "default"
? "gap-0.5 rounded-xl border border-border bg-card p-1"
: "rounded-lg",
disabled && "opacity-60",
className,
)}
{...domProps}
>
{children}
</motion.div>
</ToggleGroupContext.Provider>
);
}
export interface ToggleGroupItemProps extends ButtonBaseProps {
value: string;
children?: ReactNode;
}
export const ToggleGroupItem = forwardRef<HTMLButtonElement, ToggleGroupItemProps>(
function ToggleGroupItem(
{ value, disabled: itemDisabled, className, children, onClick, onFocus, ...rest },
ref,
) {
const ctx = useContext(ToggleGroupContext);
if (!ctx) throw new Error("ToggleGroupItem must be used inside <ToggleGroup>.");
const reduce = useReducedMotion();
const disabled = ctx.disabled || Boolean(itemDisabled);
const on = ctx.pressed.includes(value);
const outline = ctx.variant === "outline";
return (
<motion.button
ref={ref}
type="button"
data-toggle-group-item=""
data-value={value}
data-state={on ? "on" : "off"}
aria-pressed={on}
disabled={disabled}
tabIndex={ctx.tabStop === value ? 0 : -1}
whileTap={reduce || disabled ? undefined : { scale: 0.88 }}
transition={SPRING_PRESS}
onFocus={(event) => {
ctx.setTabStop(value);
onFocus?.(event);
}}
onClick={(event) => {
onClick?.(event);
if (event.defaultPrevented) return;
ctx.setTabStop(value);
ctx.toggle(value);
}}
className={cn(
itemBase,
TOUCH_TARGET_CLASS,
sizeClass[ctx.size],
outline
? // Joined segments: neighbours overlap their shared border.
"rounded-none border border-border bg-card -ml-px first:ml-0 first:rounded-l-lg last:rounded-r-lg in-data-[orientation=vertical]:-mt-px in-data-[orientation=vertical]:ml-0 in-data-[orientation=vertical]:first:mt-0 in-data-[orientation=vertical]:first:rounded-l-none in-data-[orientation=vertical]:first:rounded-t-lg in-data-[orientation=vertical]:last:rounded-r-none in-data-[orientation=vertical]:last:rounded-b-lg"
: "rounded-lg focus-visible:ring-offset-1 focus-visible:ring-offset-card",
on
? "text-foreground"
: "text-muted-foreground enabled:hover:bg-muted/60 enabled:hover:text-foreground",
className,
)}
{...rest}
>
{ctx.type === "single" ? (
on ? (
<motion.span
layoutId={ctx.layoutId}
aria-hidden
transition={reduce ? { duration: 0 } : SPRING_LAYOUT}
className={cn(
"absolute inset-0 -z-10 rounded-[inherit]",
surfaceClass[ctx.variant],
)}
/>
) : null
) : (
<PopSurface show={on} variant={ctx.variant} />
)}
{children}
</motion.button>
);
},
);
@karaui not found? Add it to components.json
{
"registries": {
"@karaui": "https://karaui.kamisama-v0.my.id/r/{name}.json"
}
}@karaui is in shadcn's public registry directory, so the latest CLI needs no setup. On an older CLI, merge this into the existing file once and every @karaui/… install resolves.
API Reference
ToggleGroup
className?string—Toggle
className?string—variant?"outline" | "default"defaultsize?"sm" | "md" | "lg"mdpressed?boolean—defaultPressed?booleanfalseonPressedChange?((pressed: boolean) => void)—ToggleGroupItem
valuestring—className?string—Updated