Selection Actions
A floating bar of bulk actions that fades and rises in once a selection leaves zero, for pairing with Table or any selectable list.
| Name | |
|---|---|
| Priya Nair | |
| Theo Marsh | |
| Aiko Sato |
1 selected
"use client";
import { Archive, Tag, Trash2 } from "lucide-react";
import { useState } from "react";
import { Checkbox } from "@/components/motion/checkbox";
import { SelectionActions } from "@/components/motion/selection-actions";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/motion/table";
const ROWS = [
{ id: "1", name: "Priya Nair" },
{ id: "2", name: "Theo Marsh" },
{ id: "3", name: "Aiko Sato" },
];
const ACTION_BUTTON =
"relative inline-flex h-7 w-7 shrink-0 touch-manipulation items-center justify-center rounded-full outline-none transition-colors duration-150 after:absolute after:-inset-1.5 hover:bg-background/15 focus-visible:ring-2 focus-visible:ring-background/60";
export function SelectionActionsPreview() {
const [selected, setSelected] = useState<Set<string>>(new Set(["1"]));
const toggle = (id: string) =>
setSelected((current) => {
const next = new Set(current);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
return (
<div className="flex w-full max-w-sm flex-col items-center gap-4">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10" />
<TableHead>Name</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{ROWS.map((row) => (
<TableRow key={row.id} selected={selected.has(row.id)}>
<TableCell className="w-10">
<Checkbox
checked={selected.has(row.id)}
onCheckedChange={() => toggle(row.id)}
aria-label={`Select ${row.name}`}
/>
</TableCell>
<TableCell emphasis>{row.name}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<SelectionActions count={selected.size} onClear={() => setSelected(new Set())}>
<button type="button" aria-label="Tag" className={ACTION_BUTTON}>
<Tag aria-hidden="true" className="h-3.5 w-3.5" />
</button>
<button type="button" aria-label="Archive" className={ACTION_BUTTON}>
<Archive aria-hidden="true" className="h-3.5 w-3.5" />
</button>
<button type="button" aria-label="Delete" className={ACTION_BUTTON}>
<Trash2 aria-hidden="true" className="h-3.5 w-3.5" />
</button>
</SelectionActions>
</div>
);
}
"use client";
// easeui.dev/components/motion/selection-actions
import { X } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import type { ReactNode } from "react";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
export interface SelectionActionsProps {
/** How many items are selected. The bar hides itself at 0. */
count: number;
onClear?: () => void;
/** Action buttons, such as icon buttons for delete or tag. */
children: ReactNode;
className?: string;
}
/**
* A bar for bulk actions that floats over the page rather than sitting in
* flow, so rows in a Table (or any list) above it never jump when it
* appears or disappears. Fixed to the bottom of the viewport, centered.
* Fades and rises in once count leaves zero, and back out once the
* selection clears.
*/
export function SelectionActions({ count, onClear, children, className }: SelectionActionsProps) {
const reduce = useReducedMotion();
return (
<AnimatePresence>
{count > 0 ? (
<motion.div
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 8, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: 8, scale: 0.97 }}
transition={{ duration: reduce ? 0.1 : 0.2, ease: EASE_OUT }}
className={cn(
"fixed inset-x-0 bottom-6 z-40 mx-auto flex w-fit items-center gap-2 rounded-full bg-foreground py-1.5 pl-1.5 pr-3 text-background shadow-[0_12px_24px_-12px_rgb(0_0_0/0.4)]",
className,
)}
>
<button
type="button"
onClick={onClear}
aria-label="Clear selection"
className="relative inline-flex h-7 w-7 shrink-0 touch-manipulation items-center justify-center rounded-full outline-none transition-colors duration-150 after:absolute after:-inset-1.5 hover:bg-background/15 focus-visible:ring-2 focus-visible:ring-background/60"
>
<X aria-hidden="true" className="h-3.5 w-3.5" />
</button>
<span className="text-sm font-medium tabular-nums">{count} selected</span>
<div className="mx-1 h-4 w-px bg-background/25" />
<div className="flex items-center gap-1">{children}</div>
</motion.div>
) : null}
</AnimatePresence>
);
}
Installation
$ bunx --bun shadcn add @easeui/selection-actions
- 1
Set up the theme tokens
Do this once per project. Follow the theme setup or skip it if you already ran shadcn init.
- 2
Install the dependencies
terminal npm install clsx lucide-react motion tailwind-merge - 3
Add the source files
components/motion/selection-actions.tsx "use client"; // easeui.dev/components/motion/selection-actions import { X } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import type { ReactNode } from "react"; import { EASE_OUT } from "@/lib/ease"; import { cn } from "@/lib/utils"; export interface SelectionActionsProps { /** How many items are selected. The bar hides itself at 0. */ count: number; onClear?: () => void; /** Action buttons, such as icon buttons for delete or tag. */ children: ReactNode; className?: string; } /** * A bar for bulk actions that floats over the page rather than sitting in * flow, so rows in a Table (or any list) above it never jump when it * appears or disappears. Fixed to the bottom of the viewport, centered. * Fades and rises in once count leaves zero, and back out once the * selection clears. */ export function SelectionActions({ count, onClear, children, className }: SelectionActionsProps) { const reduce = useReducedMotion(); return ( <AnimatePresence> {count > 0 ? ( <motion.div initial={reduce ? { opacity: 0 } : { opacity: 0, y: 8, scale: 0.97 }} animate={{ opacity: 1, y: 0, scale: 1 }} exit={reduce ? { opacity: 0 } : { opacity: 0, y: 8, scale: 0.97 }} transition={{ duration: reduce ? 0.1 : 0.2, ease: EASE_OUT }} className={cn( "fixed inset-x-0 bottom-6 z-40 mx-auto flex w-fit items-center gap-2 rounded-full bg-foreground py-1.5 pl-1.5 pr-3 text-background shadow-[0_12px_24px_-12px_rgb(0_0_0/0.4)]", className, )} > <button type="button" onClick={onClear} aria-label="Clear selection" className="relative inline-flex h-7 w-7 shrink-0 touch-manipulation items-center justify-center rounded-full outline-none transition-colors duration-150 after:absolute after:-inset-1.5 hover:bg-background/15 focus-visible:ring-2 focus-visible:ring-background/60" > <X aria-hidden="true" className="h-3.5 w-3.5" /> </button> <span className="text-sm font-medium tabular-nums">{count} selected</span> <div className="mx-1 h-4 w-px bg-background/25" /> <div className="flex items-center gap-1">{children}</div> </motion.div> ) : null} </AnimatePresence> ); }lib/ease.ts // Shared motion tokens. Micro-interactions run 100 to 150ms, standard UI // 150 to 250ms, and panels up to 300ms. // Easing curves mirror the CSS custom properties in globals.css. /** ease-out-quint. Fast start that settles quickly. Entrances, exits, feedback. */ export const EASE_OUT = [0.23, 1, 0.32, 1] as const; /** ease-in-out-cubic. Elements already on screen moving to a new spot. */ export const EASE_IN_OUT = [0.645, 0.045, 0.355, 1] as const; /** Sheet and drawer glide. */ 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.23, 1, 0.32, 1)"; // Springs are described by duration and bounce, which is easier to reason about // than stiffness and damping. Bounce stays at zero for product UI. /** Press feedback on buttons and other tappable surfaces. */ export const SPRING_PRESS = { type: "spring", duration: 0.15, bounce: 0, } as const; /** Content swaps, label and icon slots trading places inside a control. */ export const SPRING_SWAP = { type: "spring", duration: 0.2, bounce: 0, } as const; /** Overlay panel entrances, modals and sheets summoned by pointer. */ export const SPRING_PANEL = { type: "spring", duration: 0.25, bounce: 0, } as const; /** Shared-layout glides, pills and indicators moving between positions. */ export const SPRING_LAYOUT = { type: "spring", duration: 0.22, bounce: 0, } as const; /** Cursor-follow physics for decorative mouse tracking (magnetic, tilt). */ export const SPRING_MOUSE = { stiffness: 320, damping: 26, mass: 0.3, } as const; /** Dragged handles and fills (sliders). Critically damped `useSpring` config, * so the value follows the pointer closely and never rebounds off an end. */ export const SPRING_GLIDE = { stiffness: 700, damping: 50, mass: 0.5, } as const;lib/utils.ts import { clsx, type ClassValue } from "clsx" import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) }components/motion/checkbox.tsx "use client"; import { Check } from "lucide-react"; import { forwardRef, type InputHTMLAttributes, useState } from "react"; import { cn } from "@/lib/utils"; export interface CheckboxProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type"> { /** Controlled checked state. */ checked?: boolean; /** Starting state when uncontrolled. Default false. */ defaultChecked?: boolean; /** Called with the next state each time the checkbox is toggled. */ onCheckedChange?: (checked: boolean) => void; } /** * A checkbox on a real checkbox input, with a check mark that pops in rather * than just appearing. Put it inside a label and the whole row becomes the * tap target, with no dead space between the text and the box. */ export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(function Checkbox( { checked, defaultChecked = false, onCheckedChange, className, onChange, ...props }, ref, ) { const [uncontrolled, setUncontrolled] = useState(defaultChecked); const isControlled = checked !== undefined; const on = isControlled ? checked : uncontrolled; return ( <span className={cn("relative inline-flex h-5 w-5 shrink-0 items-center justify-center", className)}> <input ref={ref} type="checkbox" checked={on} onChange={(event) => { onChange?.(event); if (!isControlled) setUncontrolled(event.target.checked); onCheckedChange?.(event.target.checked); }} className={cn( "peer absolute inset-0 m-0 h-5 w-5 shrink-0 touch-manipulation cursor-pointer appearance-none rounded-md outline-none", "shadow-[0_0_0_1px_var(--border-strong)] transition-colors duration-150 ease-out", "checked:bg-accent checked:shadow-[0_0_0_1px_var(--accent)]", "focus-visible:ring-2 focus-visible:ring-foreground/40 focus-visible:ring-offset-2 focus-visible:ring-offset-background", "disabled:cursor-not-allowed disabled:opacity-50", // Grows the hit area to about 44px without changing the layout. "after:absolute after:-inset-3", )} {...props} /> <Check aria-hidden="true" strokeWidth={3} className="pointer-events-none relative h-3.5 w-3.5 scale-50 text-accent-foreground opacity-0 transition-[opacity,scale] duration-150 ease-out peer-checked:scale-100 peer-checked:opacity-100 motion-reduce:transition-none" /> </span> ); });components/motion/table.tsx import { ArrowUp } from "lucide-react"; import type { HTMLAttributes, TdHTMLAttributes, ThHTMLAttributes } from "react"; import { cn } from "@/lib/utils"; /** Smallest width a dragged column can shrink to. */ const MIN_COLUMN_WIDTH = 80; export function Table({ className, ...props }: HTMLAttributes<HTMLTableElement>) { return ( <div className="w-full overflow-x-auto rounded-2xl shadow-[0_0_0_1px_var(--border)]"> <table className={cn("w-full border-collapse text-sm", className)} {...props} /> </div> ); } export function TableHeader({ className, ...props }: HTMLAttributes<HTMLTableSectionElement>) { return <thead className={cn("bg-muted/50", className)} {...props} />; } export function TableBody({ className, ...props }: HTMLAttributes<HTMLTableSectionElement>) { return <tbody className={cn("divide-y divide-border", className)} {...props} />; } export interface TableRowProps extends HTMLAttributes<HTMLTableRowElement> { /** Highlights the row as selected. Default false. */ selected?: boolean; } export function TableRow({ selected = false, className, ...props }: TableRowProps) { return ( <tr data-selected={selected} className={cn( "transition-colors duration-150 hover:bg-muted/40 data-[selected=true]:bg-accent/5", className, )} {...props} /> ); } export interface TableHeadProps extends ThHTMLAttributes<HTMLTableCellElement> { /** Shows a sort arrow and makes the header clickable. Omit for a plain, unsortable column. */ sorted?: "asc" | "desc" | false; onSort?: () => void; /** Shows a drag handle on the trailing edge; reports the column's new width in pixels as the pointer moves. Width itself stays the caller's state, same as sort. */ onResize?: (width: number) => void; } /** A thin strip on a header's trailing edge that drags the column wider or narrower. */ function ResizeHandle({ onResize }: { onResize: (width: number) => void }) { return ( <span aria-hidden="true" onPointerDown={(event) => { event.preventDefault(); const th = event.currentTarget.closest("th"); if (!th) return; const startX = event.clientX; const startWidth = th.getBoundingClientRect().width; const previousCursor = document.body.style.cursor; const previousSelect = document.body.style.userSelect; document.body.style.cursor = "col-resize"; document.body.style.userSelect = "none"; const move = (moveEvent: PointerEvent) => { onResize(Math.max(MIN_COLUMN_WIDTH, startWidth + moveEvent.clientX - startX)); }; const finish = () => { window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", finish); document.body.style.cursor = previousCursor; document.body.style.userSelect = previousSelect; }; window.addEventListener("pointermove", move); window.addEventListener("pointerup", finish); }} className="absolute inset-y-2 right-0 w-1 cursor-col-resize touch-none rounded-full bg-foreground/15 opacity-0 transition-opacity duration-150 group-hover:opacity-100" /> ); } /** A column heading. Pass onSort and it becomes a button with a sort arrow that only shows on hover until active. Pass onResize to add a drag handle on its trailing edge. */ export function TableHead({ sorted = false, onSort, onResize, className, children, ...props }: TableHeadProps) { if (!onSort) { return ( <th className={cn( "group relative h-10 px-3 text-left text-xs font-medium text-muted-foreground", className, )} {...props} > {children} {onResize ? <ResizeHandle onResize={onResize} /> : null} </th> ); } return ( <th className={cn("group relative h-10 px-1 text-left text-xs font-medium text-muted-foreground", className)} {...props} > <button type="button" onClick={onSort} className="inline-flex h-8 touch-manipulation items-center gap-1 rounded-md px-2 outline-none transition-colors duration-150 hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-foreground/40" > {children} <ArrowUp aria-hidden="true" className={cn( "h-3 w-3 transition-[opacity,transform] duration-150", sorted ? "opacity-100" : "opacity-0 group-hover:opacity-40", sorted === "desc" && "rotate-180", )} /> </button> {onResize ? <ResizeHandle onResize={onResize} /> : null} </th> ); } export interface TableCellProps extends TdHTMLAttributes<HTMLTableCellElement> { /** Bolds the cell in the foreground color, for a row's primary column, such as a name. Default false. */ emphasis?: boolean; /** Shows a muted "Calculating…" placeholder in place of children, for a value still being computed. Default false. */ loading?: boolean; } export function TableCell({ emphasis = false, loading = false, className, children, ...props }: TableCellProps) { return ( <td className={cn("px-3 py-2.5 align-middle", emphasis && "font-medium text-foreground", className)} {...props} > {loading ? ( <span className="inline-flex items-center gap-1.5 text-muted-foreground"> Calculating… <span aria-hidden="true" className="h-1.5 w-1.5 animate-pulse rounded-full bg-muted-foreground motion-reduce:animate-none" /> </span> ) : ( children )} </td> ); }
API reference
| Prop | Type | Default | Description |
|---|---|---|---|
| count | number | - | How many items are selected. The bar hides itself at 0. |
| onClear? | (() => void) | - | - |
| children | ReactNode | - | Action buttons, such as icon buttons for delete or tag. |
| className? | string | - | - |
Updated