Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Bulk delete

New

A file list where you drag to select a run of rows; deleted rows stack into one dark bar with Undo, which deals them back.

bulk-delete/bulk-delete.tsx
Live · interactive

Recent files4

1selected

  • Brand guidelines.pdf
    2.4 MB · Edited by Mia
  • Landing page v3.fig
    Edited by Leo · 2h ago
  • Moodboard.png
    5.1 MB · Edited by you
  • Q4 budget.xlsx
    86 KB · Yesterday

The live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.

When to use

  • A short list of files or records where the reader often clears several at once.
  • Deletes that are cheap to take back: the files wait in the bar until Undo or the bin.
  • Lists where a run of rows is the usual pick, so a press and a drag beats ticking each box.
  • A card in a side panel or a dashboard, about 320 to 400 px wide.

When not to use

  • Hundreds of rows with sorting and filters. Use a data table with a selection column.
  • Deletes that cannot wait, such as a request that frees storage now. Ask in a dialog instead.
  • One item at a time. A menu on the row, or a swipe on touch, is quicker.
  • Lists the reader reorders by dragging: the drag would fight the selection.

You can add this component with the shadcn CLI or by hand:

Into components/bulk-delete of any React project, with no setup:

Terminal
npx shadcn@latest add https://brunodelorence.com/r/bulk-delete.json

Either way it needs React 19 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: it follows your page’s color-scheme, light unless the page sets one, or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.

Usage

recent-files.tsx
'use client';import { BulkDelete, type FileItem } from './bulk-delete';const files: FileItem[] = [  { id: 'deck', name: 'Pitch deck.pdf', kind: 'pdf', meta: '3.2 MB · Edited by you' },  { id: 'logo', name: 'Logo final.png', kind: 'png', meta: '640 KB · Yesterday' },  { id: 'plan', name: 'Hiring plan.xlsx', kind: 'xls', meta: '120 KB · Edited by Sam' },];export function RecentFiles() {  return (    <BulkDelete      files={files}      onDelete={(gone) => console.log('trashed', gone)}      onUndo={(back) => console.log('restored', back)}      onPurge={(gone) => console.log('deleted for good', gone)}      autoplay={false}    />  );}

Each state below is a live instance, locked so you can study it.

The pieces it is built from, each with its measurements read live.

A card of recent files. Press a row and drag up or down to select a run, or tick the boxes and Shift-click; the selected rows share one grey block and the header turns into the count and a red Delete. Delete gathers the rows into a deck that becomes a dark bar, “3 files deleted”, with a sliver of the cards under it, and the list closes up. Undo deals them back to their places one after the other; the bin on the bar empties them for good. Deleting more while the bar is up adds them to it.

  • Every change is FLIP: before it, each row’s and the bar’s place is read from the screen; once React has drawn the new list, a layout effect moves each one back to where it stood with a transform and lets it go with the Web Animations API. Reading the place on screen means an interrupted move carries on from where it was.
  • The spring is simulated once, when the module loads, and sampled into a CSS linear() easing, so the browser plays the overshoot without script; browsers without linear() get an overshooting cubic-bezier.
  • On Delete, the first selected row becomes the bar: it slides to the bar’s place as its fill turns from the selection grey to near-black, its face blurs out and the bar’s words blur in. The other rows become the deck’s cards, flying in under it and narrowing to their inset, by a scaleX taken from the widths.
  • Undo plays it backwards, staggered by 45 ms: each restored row starts on its card in the deck, dark or grey and narrow, and springs to its own place with its fill, corners and face. The card’s height springs with the list.
  • Emptying the bar leaves a copy of it to fade where it was, outside React, while the rows under it close up.
  • Selected rows are one block because each knows whether its neighbours are selected and squares the corners it shares. The drag picks the row under the pointer by its place on screen, and selects from where it began.
  • The demo drags over the first rows, one too far and back, deletes them, undoes, unticks one, deletes two and empties the bar. Any input of the reader’s stops it.
PropTypeDefaultDescription
filesreadonly FileItem[]FILESThe files, in order: an id, a name, a kind for the badge (pdf, fig, png, xls, doc, zip) and the line under the name.
titlestring'Recent files'The header’s title.
onDelete(files: FileItem[]) => void—Files went into the bar. Undo can still bring them back.
onUndo(files: FileItem[]) => void—Undo brought these files back.
onPurge(files: FileItem[]) => void—The bin on the bar emptied these files for good.
onViewAll() => void—The header’s View all.
defaultSelectedreadonly string[]—Opens with these files selected (their ids); turns the demo off.
defaultDeletedreadonly string[]—Opens with these files already in the bar (their ids); turns the demo off.
autoplaybooleantrueWhile on screen and untouched, a demo pointer selects, deletes and undoes; any input hands it to the reader.
colorScheme'light' | 'dark' | 'system'—Light or dark colours, or the reader’s; without it, the page’s color-scheme.
classNamestring—Classes for the stage.

The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.

bulk-delete.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import {  useEffect,  useEffectEvent,  useId,  useLayoutEffect,  useMemo,  useRef,  useState,  type KeyboardEvent as ReactKeyboardEvent,  type PointerEvent as ReactPointerEvent,  type ReactNode,} from 'react';import styles from './bulk-delete.module.css';import { FILES, type FileItem } from './data';import { DemoPointer, runDemo, type Point } from './demo-pointer';import { useAutoplay } from './use-autoplay';export type { FileItem, FileKind } from './data';/*
bulk-delete.module.css
/* Bulk delete (bulk-delete.tsx): a white card of recent files on a warm light-grey stage, or a   dark card on a near-black one. The card is 360 px wide: a 42 px header, then 56 px rows on a   6 px inset. Selected rows share one grey block (rows next to each other join their corners);   deleted ones become a dark bar with the deck's edges showing under it.   Colours are the tokens below, light-dark(light, dark) where the two differ: the component takes   the color-scheme it inherits from the page (light unless the page says otherwise), or the one   its colorScheme prop sets (data-color-scheme). */.root {  /* The stage and the card. */  --stage: #eeefed;  --card: #fdfdfd;  --card-edge: rgb(0 0 0 / 0.045);  --card-shadow: rgb(0 0 0 / 0.05);  /* Text. */  --ink: #111111;  --ink-2: #767676;  /* Rows: under the pointer, and selected. */  --hover: #f6f6f6;  --selected: #efefef;  /* The box, empty and ticked. */  --box: #ffffff;  --box-edge: #cccccc;
data.ts
// Copyright (c) 2026 Bruno Delorence. MIT License./** The kinds of file the list draws a badge for: the badge reads the kind, in its own tint. */export type FileKind = 'pdf' | 'fig' | 'png' | 'xls' | 'doc' | 'zip';export type FileItem = {  /** Unique within the list. */  id: string;  /** The file's name, with its extension. */  name: string;  kind: FileKind;  /** The quiet line under the name: size, who edited it, when. */  meta: string;};/** The demo's recent files. */export const FILES: readonly FileItem[] = [  { id: 'brand', name: 'Brand guidelines.pdf', kind: 'pdf', meta: '2.4 MB · Edited by Mia' },  { id: 'landing', name: 'Landing page v3.fig', kind: 'fig', meta: 'Edited by Leo · 2h ago' },  { id: 'moodboard', name: 'Moodboard.png', kind: 'png', meta: '5.1 MB · Edited by you' },  { id: 'budget', name: 'Q4 budget.xlsx', kind: 'xls', meta: '86 KB · Yesterday' },];/** Two more, for longer lists in the docs. */
demo-pointer.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import type { CSSProperties, Ref } from 'react';/* * The demo's pointer, and the steps that move it. A demo is a script of awaited steps: wait, or * glide the pointer somewhere. Stopping the demo (the reader takes over, or it scrolls away) ends * the step in progress at once: its promise rejects, which ends the script right there, and the * pointer fades out. * * Each glide is a curved flight played by the Web Animations API, so nothing runs frame by frame. * The points of the flight are handed to whoever wants to move along with it. The arrow and the * hand are HTML boxes * that swap by their visibility: a change of style inside an SVG would lay the SVG out again. */export type Point = { x: number; y: number };/** A glide's path and timing, for an animation that plays along with it. */export type Flight = { points: Point[]; timing: KeyframeAnimationOptions };export type Demo = {
use-autoplay.ts
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useCallback, useEffect, useRef, useState } from 'react';/* * When a demo may play on its own: while part of it is on screen and the tab is visible, never * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it * takes back over `idle` ms after their last input. */export function useAutoplay<T extends HTMLElement>(idle = 6000) {  const ref = useRef<T>(null);  const [visible, setVisible] = useState(false);  const [reduced, setReduced] = useState(true);  const [taken, setTaken] = useState(false);  const timer = useRef(0);  useEffect(() => {    const element = ref.current;    if (!element) return;    let onScreen = false;    const update = () => setVisible(onScreen && !document.hidden);    const observer = new IntersectionObserver(
  • The card is a section named by its title; the files are a list, and each row has a real checkbox (a button with role checkbox) named by the file, its line under the name as its description.
  • The list is one tab stop: Up, Down, Home and End move between rows, Shift with an arrow or Shift with Space selects from the anchor, Space ticks a row, Control or Command A selects all and Escape clears.
  • Delete or Backspace deletes the selection, and Control or Command Z undoes, from anywhere in the card. The focus moves to Undo after a delete, back to the first restored row after Undo, and to the list after the bin.
  • Selecting, deleting, restoring and emptying are announced in a polite live region, with how many files.
  • On touch, a row scrolls the page: a tap ticks it, and a drag that starts on a box selects a run.
  • Every control has a visible focus ring. Under reduced motion, nothing flies or springs; the list changes in place.
  • The demo pointer is decorative and hidden from assistive technology.