Overflow Actions
Keeps the main buttons visible in one joined pill and tucks the rest behind a toggle; pressing it springs the rail wider to show the extra actions, and it can close again after one is used.
Preview
Install
Add it with the shadcn CLI, or copy the source manually.
$ bunx --bun shadcn add @karaui/overflow-actions
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/hooks/use-hover-capable.ts
"use client";
import { useEffect, useState } from "react";
/**
* Returns true only on devices that have a true hover (mouse / trackpad).
* Touch devices fire phantom `:hover` on tap that sticks until tap-elsewhere
* — gate hover-only effects (scale lifts, magnetic pulls) behind this.
*/
export function useHoverCapable() {
const [canHover, setCanHover] = useState(false);
useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) return;
const mq = window.matchMedia("(hover: hover) and (pointer: fine)");
const update = () => setCanHover(mq.matches);
update();
mq.addEventListener?.("change", update);
return () => mq.removeEventListener?.("change", update);
}, []);
return canHover;
}
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/overflow-actions.tsx
"use client";
// karaui.kamisama-v0.my.id/components/blocks/overflow-actions
import { MoreHorizontal, X } from "lucide-react";
import {
AnimatePresence,
motion,
useReducedMotion,
type Transition,
type Variants,
} from "motion/react";
import {
useCallback,
useId,
useLayoutEffect,
useRef,
useState,
type ReactNode,
} from "react";
import { EASE_OUT } from "@/lib/ease";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import { cn } from "@/lib/utils";
export type OverflowActionsSize = "sm" | "md";
export type OverflowActionItem = {
id: string;
label: ReactNode;
icon?: ReactNode;
onClick?: () => void;
disabled?: boolean;
ariaLabel?: string;
};
export type OverflowActionsClassNames = {
root?: string;
track?: string;
action?: string;
primaryAction?: string;
overflowAction?: string;
toggle?: string;
icon?: string;
label?: string;
};
export interface OverflowActionsProps {
primaryActions: OverflowActionItem[];
overflowActions: OverflowActionItem[];
expanded?: boolean;
defaultExpanded?: boolean;
onExpandedChange?: (expanded: boolean) => void;
onAction?: (item: OverflowActionItem) => void;
collapseOnAction?: boolean;
size?: OverflowActionsSize;
openLabel?: string;
closeLabel?: string;
className?: string;
classNames?: OverflowActionsClassNames;
}
// This needs a softer layout spring than the app defaults so the overflow group
// stays visually attached to the toggle while entering and leaving.
const SHELL_TRANSITION: Transition = {
type: "spring",
stiffness: 220,
damping: 17,
mass: 0.85,
};
const ICON_VARIANTS: Variants = {
hidden: { opacity: 0, filter: "blur(3px)" },
visible: {
opacity: 1,
filter: "blur(0px)",
transition: { duration: 0.18, ease: EASE_OUT },
},
exit: {
opacity: 0,
filter: "blur(3px)",
transition: { duration: 0.18, ease: EASE_OUT },
},
};
const OVERFLOW_ACTION_VARIANTS: Variants = {
hidden: { opacity: 0, filter: "blur(4px)" },
visible: { opacity: 1, filter: "blur(0px)" },
exit: { opacity: 0, filter: "blur(4px)" },
};
const TRACK_SIZE_CLASS: Record<OverflowActionsSize, string> = {
sm: "gap-1 p-1 text-xs",
md: "gap-1.5 p-1.5 text-sm",
};
const GROUP_GAP_CLASS: Record<OverflowActionsSize, string> = {
sm: "gap-1",
md: "gap-1.5",
};
const ACTION_SIZE_CLASS: Record<OverflowActionsSize, string> = {
sm: "h-8 min-w-8 gap-1.5 px-3",
md: "h-9 min-w-9 gap-2 px-3.5",
};
const TOGGLE_SIZE_CLASS: Record<OverflowActionsSize, string> = {
sm: "h-8 w-8",
md: "h-9 w-9",
};
const ICON_SIZE_CLASS: Record<OverflowActionsSize, string> = {
sm: "h-3.5 w-3.5",
md: "h-4 w-4",
};
function useControllableExpanded({
expanded,
defaultExpanded,
onExpandedChange,
}: {
expanded?: boolean;
defaultExpanded?: boolean;
onExpandedChange?: (expanded: boolean) => void;
}) {
const [internalExpanded, setInternalExpanded] = useState(
defaultExpanded ?? false,
);
const isControlled = expanded !== undefined;
const value = expanded ?? internalExpanded;
const setValue = useCallback(
(next: boolean) => {
if (!isControlled) setInternalExpanded(next);
onExpandedChange?.(next);
},
[isControlled, onExpandedChange],
);
return [value, setValue] as const;
}
export function OverflowActions({
primaryActions,
overflowActions,
expanded,
defaultExpanded = false,
onExpandedChange,
onAction,
collapseOnAction = false,
size = "md",
openLabel = "Show extra actions",
closeLabel = "Hide extra actions",
className,
classNames,
}: OverflowActionsProps) {
const reduce = useReducedMotion();
const canHover = useHoverCapable();
const overflowId = useId();
const overflowWrapperRef = useRef<HTMLDivElement>(null);
const overflowWrapperLeftRef = useRef(0);
const [isExpanded, setIsExpanded] = useControllableExpanded({
expanded,
defaultExpanded,
onExpandedChange,
});
const transition = reduce ? { duration: 0 } : SHELL_TRANSITION;
useLayoutEffect(() => {
const overflowNode = overflowWrapperRef.current;
if (!overflowNode) return;
if (!isExpanded) {
overflowNode.style.left = `${
overflowWrapperLeftRef.current -
overflowNode.getBoundingClientRect().left
}px`;
return;
}
overflowNode.style.left = "";
overflowWrapperLeftRef.current = overflowNode.getBoundingClientRect().left;
}, [isExpanded]);
const handleAction = (item: OverflowActionItem) => {
item.onClick?.();
onAction?.(item);
if (collapseOnAction) setIsExpanded(false);
};
return (
<motion.div
layout
transition={transition}
className={cn("inline-flex", classNames?.root, className)}
>
<motion.div
layout
transition={transition}
className={cn(
"relative inline-flex items-center overflow-hidden rounded-full border border-border bg-card",
TRACK_SIZE_CLASS[size],
classNames?.track,
)}
>
<motion.div
layout
transition={transition}
className={cn("inline-flex items-center", GROUP_GAP_CLASS[size])}
>
{primaryActions.map((item) => (
<ActionButton
key={item.id}
item={item}
size={size}
reduce={reduce}
canHover={canHover}
onAction={handleAction}
layoutTransition={transition}
className={cn(classNames?.action, classNames?.primaryAction)}
iconClassName={classNames?.icon}
labelClassName={classNames?.label}
/>
))}
</motion.div>
<AnimatePresence mode="popLayout" initial={false}>
{isExpanded ? (
<motion.div
key="overflow-actions"
ref={overflowWrapperRef}
id={overflowId}
layout
aria-hidden={!isExpanded}
transition={transition}
className={cn(
"relative inline-flex w-max items-center",
GROUP_GAP_CLASS[size],
)}
>
{overflowActions.map((item) => (
<ActionButton
key={item.id}
item={item}
size={size}
reduce={reduce}
canHover={canHover}
overflow
visible={isExpanded}
variants={OVERFLOW_ACTION_VARIANTS}
onAction={handleAction}
layoutTransition={transition}
className={cn(classNames?.action, classNames?.overflowAction)}
iconClassName={classNames?.icon}
labelClassName={classNames?.label}
/>
))}
</motion.div>
) : null}
</AnimatePresence>
<motion.button
type="button"
layout
aria-expanded={isExpanded}
aria-controls={isExpanded ? overflowId : undefined}
aria-label={isExpanded ? closeLabel : openLabel}
title={isExpanded ? closeLabel : openLabel}
onClick={() => setIsExpanded(!isExpanded)}
whileTap={reduce ? undefined : { scale: 0.96 }}
whileHover={reduce || !canHover ? undefined : { scale: 1.03 }}
transition={transition}
className={cn(
"relative inline-grid shrink-0 place-items-center rounded-full bg-primary text-primary-foreground outline-none disabled:pointer-events-none disabled:opacity-50",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
TOGGLE_SIZE_CLASS[size],
classNames?.toggle,
)}
>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={isExpanded ? "close" : "open"}
variants={ICON_VARIANTS}
initial={reduce ? { opacity: 0 } : "hidden"}
animate={reduce ? { opacity: 1 } : "visible"}
exit={reduce ? { opacity: 0 } : "exit"}
className="inline-grid place-items-center"
>
{isExpanded ? (
<X className={ICON_SIZE_CLASS[size]} />
) : (
<MoreHorizontal className={ICON_SIZE_CLASS[size]} />
)}
</motion.span>
</AnimatePresence>
</motion.button>
</motion.div>
</motion.div>
);
}
function ActionButton({
item,
size,
reduce,
canHover,
overflow,
visible = true,
variants,
onAction,
layoutTransition,
className,
iconClassName,
labelClassName,
}: {
item: OverflowActionItem;
size: OverflowActionsSize;
reduce: boolean | null;
canHover: boolean;
overflow?: boolean;
visible?: boolean;
variants?: Variants;
onAction: (item: OverflowActionItem) => void;
layoutTransition: Transition;
className?: string;
iconClassName?: string;
labelClassName?: string;
}) {
const label = typeof item.label === "string" ? item.label : undefined;
return (
<motion.span
layout="position"
variants={variants}
initial={variants ? (reduce ? { opacity: 0 } : "hidden") : undefined}
animate={variants ? (reduce ? { opacity: 1 } : "visible") : undefined}
exit={variants ? (reduce ? { opacity: 0 } : "exit") : undefined}
whileTap={reduce || item.disabled ? undefined : { scale: 0.97 }}
whileHover={
reduce || !canHover || item.disabled ? undefined : { scale: 1.008 }
}
transition={layoutTransition}
className="inline-flex shrink-0"
>
<button
type="button"
disabled={item.disabled}
aria-label={item.ariaLabel}
tabIndex={overflow && !visible ? -1 : undefined}
title={label}
onClick={() => onAction(item)}
className={cn(
"inline-flex shrink-0 items-center justify-center rounded-full bg-background font-medium text-foreground outline-none",
"disabled:pointer-events-none disabled:opacity-45",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
ACTION_SIZE_CLASS[size],
className,
)}
>
{item.icon ? (
<span
className={cn(
"inline-flex shrink-0 items-center justify-center",
ICON_SIZE_CLASS[size],
iconClassName,
)}
>
{item.icon}
</span>
) : null}
<span className={cn("whitespace-nowrap", labelClassName)}>
{item.label}
</span>
</button>
</motion.span>
);
}
@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
primaryActionsOverflowActionItem[]—overflowActionsOverflowActionItem[]—expanded?boolean—defaultExpanded?booleanfalseonExpandedChange?((expanded: boolean) => void)—onAction?((item: OverflowActionItem) => void)—collapseOnAction?booleanfalsesize?"sm" | "md"mdopenLabel?stringShow extra actionscloseLabel?stringHide extra actionsclassName?string—classNames?OverflowActionsClassNames—Keep in mind
Some components on this site are inspired by or recreated from existing work across the web. I'm not here to take credit; just to learn, experiment, and sometimes push things a bit further. If something looks familiar and I forgot to mention you, reach out and I'll fix that right away.
Updated