Overflow Actions
A toolbar that measures itself and moves actions into a “more” menu as space runs out, with smooth width transitions.
Installation
01
Install with the shadcn CLI. The NiceUI theme comes along the first time.
$ npx shadcn@latest add https://niceui.dev/r/blocks-overflow-actions.json02
Or copy the source below (2 files). Dependencies: lucide-react.
Source
components/niceui/blocks/OverflowActions.tsx
import { useLayoutEffect, useRef, useState, type ComponentType } from 'react';
import { Bold, Copy, Download, Italic, Link, MoreHorizontal, Pencil, Share2, Tag, Trash2, Underline } from 'lucide-react';
import { cn } from '@/lib/cn';
import { useClickOutside, useEscape, usePresence } from '@/components/niceui/ui/motion/hooks';
export type ToolbarAction = { id: string; label: string; icon: ComponentType<{ className?: string }>; danger?: boolean };
const ACTIONS: ToolbarAction[] = [
{ id: 'edit', label: 'Edit', icon: Pencil },
{ id: 'bold', label: 'Bold', icon: Bold },
{ id: 'italic', label: 'Italic', icon: Italic },
{ id: 'underline', label: 'Underline', icon: Underline },
{ id: 'link', label: 'Link', icon: Link },
{ id: 'tag', label: 'Tag', icon: Tag },
{ id: 'copy', label: 'Duplicate', icon: Copy },
{ id: 'share', label: 'Share', icon: Share2 },
{ id: 'download', label: 'Export', icon: Download },
{ id: 'delete', label: 'Delete', icon: Trash2, danger: true },
];
const MORE_W = 40;
const GAP = 2;
export type OverflowActionsProps = { actions?: ToolbarAction[]; onAction?: (a: ToolbarAction) => void };
export function OverflowActions({ actions = ACTIONS, onAction }: OverflowActionsProps) {
const wrap = useRef<HTMLDivElement>(null);
const measure = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const [widths, setWidths] = useState<number[]>([]);
const [fit, setFit] = useState(actions.length);
const [open, setOpen] = useState(false);
const { mounted, visible } = usePresence(open, 180);
useEscape(open, () => setOpen(false));
useClickOutside([menuRef], open, () => setOpen(false));
useLayoutEffect(() => {
const el = wrap.current;
const m = measure.current;
if (!el || !m) return;
const ws = Array.from(m.children).map((c) => (c as HTMLElement).offsetWidth);
setWidths(ws);
const compute = () => {
const avail = el.clientWidth - 12;
const total = ws.reduce((a, b) => a + b + GAP, 0);
if (total <= avail) return setFit(ws.length);
let used = MORE_W;
let n = 0;
while (n < ws.length && used + ws[n] + GAP <= avail) used += ws[n++] + GAP;
setFit(n);
};
compute();
const ro = new ResizeObserver(compute);
ro.observe(el);
return () => ro.disconnect();
}, [actions]);
const overflow = actions.slice(fit);
const btn = 'focus-ring flex h-9 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-lg px-2.5 text-sm transition-colors';
return (
<div ref={wrap} className="relative w-full">
<div ref={measure} aria-hidden className="pointer-events-none invisible absolute flex">
{actions.map((a) => (
<span key={a.id} className={btn}><a.icon className="h-4 w-4" />{a.label}</span>
))}
</div>
<div className="flex items-center rounded-xl bg-ink-900 p-1.5 shadow-card" style={{ gap: GAP }}>
{actions.map((a, i) => {
const shown = i < fit;
return (
<button
key={a.id}
tabIndex={shown ? 0 : -1}
aria-hidden={!shown}
onClick={() => onAction?.(a)}
className={cn(btn, 'overflow-hidden duration-300 ease-smooth', a.danger ? 'text-rose-300 hover:bg-rose-500/10' : 'text-ink-300 hover:bg-white/5 hover:text-ink-50', shown ? 'opacity-100' : 'pointer-events-none !px-0 opacity-0')}
style={{ width: widths[i] !== undefined ? (shown ? widths[i] : 0) : undefined, marginRight: shown ? 0 : -GAP, transitionProperty: 'width, opacity, padding, margin, background-color, color' }}
>
<a.icon className="h-4 w-4 shrink-0" />
{a.label}
</button>
);
})}
<div ref={menuRef} className={cn('relative ml-auto transition-all duration-300 ease-spring', overflow.length ? 'w-9 scale-100 opacity-100' : 'pointer-events-none w-0 scale-50 opacity-0')}>
<button
aria-label={`${overflow.length} more actions`}
onClick={() => setOpen((o) => !o)}
className={cn('focus-ring relative flex h-9 w-9 items-center justify-center rounded-lg text-ink-300 transition-colors hover:bg-white/5 hover:text-ink-50', open && 'bg-white/5 text-ink-50')}
>
<MoreHorizontal className="h-4 w-4" />
{overflow.length > 0 && (
<span key={overflow.length} className="absolute -right-0.5 -top-0.5 flex h-4 min-w-[16px] animate-scale-in items-center justify-center rounded-full bg-brand-500 px-1 text-[10px] font-medium text-white">{overflow.length}</span>
)}
</button>
{mounted && overflow.length > 0 && (
<div className={cn('absolute right-0 top-full z-30 mt-2 w-44 origin-top-right rounded-xl bg-ink-850 p-1 shadow-pop transition-all duration-200 ease-smooth', visible ? 'scale-100 opacity-100' : 'scale-95 opacity-0')}>
{overflow.map((a, i) => (
<button
key={a.id}
onClick={() => { onAction?.(a); setOpen(false); }}
style={{ animationDelay: `${i * 25}ms` }}
className={cn('flex w-full animate-fade-up items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-left text-sm transition-colors', a.danger ? 'text-rose-300 hover:bg-rose-500/10' : 'text-ink-200 hover:bg-white/5')}
>
<a.icon className="h-4 w-4" /> {a.label}
</button>
))}
</div>
)}
</div>
</div>
</div>
);
}
export function OverflowActionsDemo() {
const [width, setWidth] = useState(78);
const [last, setLast] = useState<string | null>(null);
return (
<div className="mx-auto flex min-h-[300px] w-full max-w-2xl flex-col items-center gap-8 pt-6">
<div className="w-full transition-[width] duration-150" style={{ width: `${width}%` }}>
<OverflowActions onAction={(a) => setLast(a.label)} />
</div>
<div className="flex w-full max-w-xs flex-col gap-2">
<div className="flex justify-between text-xs text-ink-400">
<span>Container width</span>
<span className="font-mono text-ink-200">{width}%</span>
</div>
<input type="range" min={20} max={100} value={width} onChange={(e) => setWidth(Number(e.target.value))} className="w-full accent-brand-500" />
<p className="h-4 text-center text-xs text-ink-500">{last ? <span key={last} className="animate-fade-in">Ran <span className="text-ink-200">{last}</span></span> : 'Drag the slider to collapse actions'}</p>
</div>
</div>
);
}components/niceui/ui/motion/hooks.ts
import { useEffect, useState, type ReactNode, type RefObject } from 'react';
import { createPortal } from 'react-dom';
/**
* Keeps an element mounted while it animates out.
* `mounted` controls rendering, `visible` drives the enter/exit classes.
*/
export function usePresence(open: boolean, exitMs = 220) {
const [mounted, setMounted] = useState(open);
const [visible, setVisible] = useState(open);
useEffect(() => {
if (open) {
setMounted(true);
let inner = 0;
const outer = requestAnimationFrame(() => {
inner = requestAnimationFrame(() => setVisible(true));
});
return () => {
cancelAnimationFrame(outer);
cancelAnimationFrame(inner);
};
}
setVisible(false);
const t = window.setTimeout(() => setMounted(false), exitMs);
return () => window.clearTimeout(t);
}, [open, exitMs]);
return { mounted, visible };
}
/** Calls `handler` when Escape is pressed while `active`. */
export function useEscape(active: boolean, handler: () => void) {
useEffect(() => {
if (!active) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') handler();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [active, handler]);
}
/** Calls `handler` on pointerdown outside every provided ref while `active`. */
export function useClickOutside(
refs: Array<RefObject<HTMLElement>>,
active: boolean,
handler: () => void,
) {
useEffect(() => {
if (!active) return;
const onDown = (e: PointerEvent) => {
const target = e.target as Node;
if (refs.some((r) => r.current?.contains(target))) return;
handler();
};
document.addEventListener('pointerdown', onDown);
return () => document.removeEventListener('pointerdown', onDown);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [active, handler]);
}
/**
* Renders overlays into document.body so `position: fixed` is relative to the
* viewport even when an ancestor has a transform (e.g. entrance animations).
*/
export function Portal({ children }: { children: ReactNode }) {
if (typeof document === 'undefined') return null;
return createPortal(children, document.body);
}