Bulk Actions
Bulk Actions gives a selected set of application records one clear action region. It announces the current count, provides a default way to clear selection, and leaves every real destination and command in caller markup.
It accepts the count, never the selected IDs. Selection, authorization, requests, confirmation, success messages, and positioning remain application concerns. There is no action schema, visual variant, mutation client, permission callback, or toolbar state machine.
Installation
The command detects Vue, React, or Svelte and copies the matching source into the conventional UI directory.
Run one command from a Boring Stack application. Klean detects the framework and conventional destination, then adds the framework-native source and its direct dependencies.
npx klean-ui add bulk-actions- No initializer or configuration file
- No framework, alias, or theme questions
- No Klean runtime dependency
There is no initializer, provider, klean-ui.json, class helper, barrel file, or runtime Klean dependency.
Usage
Pass only the number selected. Keep the selected IDs where the table or page already owns them, and write real Links and buttons inside the action region.
Vue
<script setup>
import { Link, router } from '@inertiajs/vue3'
import { ref } from 'vue'
import BulkActions from '@/components/ui/bulk-actions/BulkActions.vue'
const selectedIds = ref(['svc_01J9api', 'svc_01J9worker'])
const processing = ref(false)
function archiveSelected() {
if (processing.value) return
processing.value = true
router.post(
'/services/archive',
{ ids: selectedIds.value },
{ onFinish: () => (processing.value = false) }
)
}
</script>
<template>
<input
type="checkbox"
aria-label="Select all services on this page"
data-bulk-actions-focus
/>
<BulkActions
:count="selectedIds.length"
:busy="processing"
label="Actions for selected services"
@clear="selectedIds = []"
>
<Link href="/services/export">Export</Link>
<button type="button" :disabled="processing" @click="archiveSelected">
Archive
</button>
</BulkActions>
</template>
React
import { Link, router } from '@inertiajs/react'
import { useState } from 'react'
import BulkActions from '@/components/ui/bulk-actions/BulkActions.jsx'
export function ServiceBulkActions() {
const [selectedIds, setSelectedIds] = useState([
'svc_01J9api',
'svc_01J9worker'
])
const [processing, setProcessing] = useState(false)
function archiveSelected() {
if (processing) return
setProcessing(true)
router.post(
'/services/archive',
{ ids: selectedIds },
{ onFinish: () => setProcessing(false) }
)
}
return (
<>
<input
type="checkbox"
aria-label="Select all services on this page"
data-bulk-actions-focus
/>
<BulkActions
count={selectedIds.length}
busy={processing}
label="Actions for selected services"
onClear={() => setSelectedIds([])}
>
<Link href="/services/export">Export</Link>
<button type="button" disabled={processing} onClick={archiveSelected}>
Archive
</button>
</BulkActions>
</>
)
}
Svelte
<script>
import { Link, router } from '@inertiajs/svelte'
import BulkActions from '@/components/ui/bulk-actions/BulkActions.svelte'
let selectedIds = $state(['svc_01J9api', 'svc_01J9worker'])
let processing = $state(false)
function archiveSelected() {
if (processing) return
processing = true
router.post(
'/services/archive',
{ ids: selectedIds },
{ onFinish: () => (processing = false) }
)
}
</script>
{#snippet actions()}
<Link href="/services/export">Export</Link>
<button type="button" disabled={processing} onclick={archiveSelected}>
Archive
</button>
{/snippet}
<input
type="checkbox"
aria-label="Select all services on this page"
data-bulk-actions-focus
/>
<BulkActions
count={selectedIds.length}
busy={processing}
label="Actions for selected services"
onclear={() => (selectedIds = [])}
children={actions}
/>
API
| Purpose | Vue | React | Svelte |
|---|---|---|---|
| Selected count | count | count | count |
| Accessible name | label | label | label |
| Pending operation | busy | busy | busy |
| Clear copy | clearLabel | clearLabel | clearLabel |
| Clear selection | @clear | onClear | onclear |
| Actions | default slot | children | children snippet |
| Count summary | #summary slot | summary | summary snippet |
| Root styling | class | className | class |
label defaults to “Bulk actions,” and clearLabel defaults to “Clear selection.” Use a result-specific name such as “Actions for selected invoices” when more than one selectable result appears on a page.
The action and summary render functions receive { count, busy, clear }. Use them when custom copy or a product wrapper benefits from the current normalized count. The component renders nothing when count is zero.
Selection and focus
Bulk selection normally belongs to DataTable or the surrounding page. Keep the selected IDs there and pass only selectedIds.length to Bulk Actions. Clearing emits one event; the caller resets its own selection.
Add data-bulk-actions-focus to the page-level checkbox or control that should receive focus when a focused action region disappears. Klean restores focus only for the zero-selection transition and only when focus was inside that disappearing region. It does not steal focus during ordinary count updates or unrelated page changes.
Links, commands, and menus
Use an anchor or framework-native Inertia Link for destinations so open-in-new-tab, copy-link, reload, server rendering, and browser history keep working. Use a button for commands. If several secondary commands need compact overflow, place a Menu trigger and menu inside the default content.
The component never turns an action description into a click handler. Render only actions authorized by the current server response, and enforce the same authorization on the server.
Busy and destructive work
busy truthfully marks the region busy and disables the default clear control. Caller actions remain caller-owned: disable only the commands that the pending operation truly makes unsafe. This keeps links usable and prevents one broad boolean from lying about every action.
A destructive action should open a Dialog that names the selected records and the consequence. The application owns confirmation, the request, progress, failure recovery, and the final selection state.
Placement and responsive behavior
Bulk Actions is an action region, not a positioning system. Put it inline above a table, make it sticky below a filter bar, or fix it near the viewport edge with ordinary caller Tailwind. This avoids hidden offsets and lets each application account for its own header, safe area, and small-screen layout.
The neutral baseline wraps actions when space narrows. Keep the summary concise, preserve visible labels for destructive work, and move secondary commands into Menu when a crowded toolbar would become difficult to scan.
Keyboard and accessibility
- The root is a named
region, so assistive technology can identify which selected result it controls. - The count is a polite, atomic status message.
- Links and buttons retain native keyboard, focus, and activation behavior.
- The default clear control is a real button with visible text.
aria-busyappears only whilebusyis true.- Focus returns deliberately when a focused toolbar disappears after clearing.
- Caller actions provide their own focus-visible Tailwind classes and complete accessible names.
Styling with Tailwind
Bulk Actions supplies a neutral wrapping layout. class or className merges onto the root, while every action is caller markup and therefore styled with ordinary Tailwind.
There is no variant, tone, size, sticky, floating, actionClass, or product theme prop. If an application repeats one treatment, wrap the copied source in a small product component with that application's classes.
When to use
Use Bulk Actions when a user selects multiple rows, invoices, services, members, deployments, or other records and needs actions that apply to that set.
When not to use
- Use Row Actions for commands that apply to one record.
- Use a normal page toolbar for commands that do not depend on selection.
- Use Filter Bar for changing which results are visible.
- Do not use bulk actions when the operation cannot clearly name or count its target set.
Complete framework source
Vue
<script setup>
import { computed, nextTick, ref, useAttrs, watch } from 'vue'
import { twMerge } from 'tailwind-merge'
defineOptions({ inheritAttrs: false })
const props = defineProps({
/** Number of records in the caller-owned selection. */
count: { type: Number, default: 0 },
/** Accessible name for the selected-record action region. */
label: { type: String, default: 'Bulk actions' },
/** Truthful pending state for work that makes clearing unsafe. */
busy: { type: Boolean, default: false },
/** Visible and accessible copy for the default clear button. */
clearLabel: { type: String, default: 'Clear selection' }
})
const emit = defineEmits(['clear'])
const attrs = useAttrs()
const element = ref()
const selectedCount = computed(() =>
Math.max(0, Math.trunc(Number(props.count) || 0))
)
const rootAttrs = computed(() => {
const {
class: _class,
role: _role,
'aria-label': _ariaLabel,
'aria-busy': _ariaBusy,
'data-slot': _dataSlot,
...rest
} = attrs
return rest
})
function focusTarget(root) {
return root?.querySelector?.('[data-bulk-actions-focus]')
}
function clearSelection() {
if (props.busy) return
emit('clear')
}
watch(
selectedCount,
async (nextCount, previousCount) => {
if (previousCount <= 0 || nextCount > 0) return
const rootElement = element.value
const root = rootElement?.getRootNode?.() ?? document
const activeElement = root.activeElement ?? document.activeElement
const shouldRestore = rootElement?.contains(activeElement)
await nextTick()
if (shouldRestore) focusTarget(root)?.focus?.({ preventScroll: true })
},
{ flush: 'pre' }
)
defineExpose({ clear: clearSelection, element })
</script>
<template>
<div
v-if="selectedCount > 0"
ref="element"
v-bind="rootAttrs"
role="region"
:aria-label="label"
:aria-busy="busy ? 'true' : undefined"
data-slot="bulk-actions"
:class="
twMerge(
'flex min-h-12 w-full flex-wrap items-center gap-3 rounded-lg border border-gray-200 bg-white px-3 py-2 text-gray-950 shadow-sm dark:border-gray-800 dark:bg-gray-950 dark:text-white',
attrs.class
)
"
>
<span
role="status"
aria-live="polite"
aria-atomic="true"
data-slot="bulk-actions-summary"
class="mr-auto text-sm font-medium tabular-nums"
>
<slot name="summary" :count="selectedCount">
{{ selectedCount }} selected
</slot>
</span>
<slot :count="selectedCount" :busy="busy" :clear="clearSelection" />
<button
type="button"
:disabled="busy"
data-slot="bulk-actions-clear"
class="min-h-9 cursor-pointer rounded-md px-3 text-sm font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-950 disabled:cursor-not-allowed disabled:opacity-50 dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-white dark:focus-visible:outline-white"
@click="clearSelection"
>
{{ clearLabel }}
</button>
</div>
</template>
React
import { forwardRef, useImperativeHandle, useLayoutEffect, useRef } from 'react'
import { twMerge } from 'tailwind-merge'
const BASE_CLASSES =
'flex min-h-12 w-full flex-wrap items-center gap-3 rounded-lg border border-gray-200 bg-white px-3 py-2 text-gray-950 shadow-sm dark:border-gray-800 dark:bg-gray-950 dark:text-white'
const BulkActions = forwardRef(function BulkActions(
{
count = 0,
label = 'Bulk actions',
busy = false,
clearLabel = 'Clear selection',
summary,
className,
children,
onClear,
'data-slot': _dataSlot,
...rootProps
},
forwardedRef
) {
const element = useRef(null)
const selectedCount = Math.max(0, Math.trunc(Number(count) || 0))
const latestCount = useRef(selectedCount)
latestCount.current = selectedCount
useImperativeHandle(forwardedRef, () => element.current)
useLayoutEffect(() => {
if (selectedCount <= 0) return undefined
const rootElement = element.current
const root = rootElement?.getRootNode?.() ?? document
return () => {
if (latestCount.current > 0) return
const activeElement = root.activeElement ?? document.activeElement
const shouldRestore = rootElement?.contains(activeElement)
if (!shouldRestore) return
queueMicrotask(() => {
root
.querySelector?.('[data-bulk-actions-focus]')
?.focus?.({ preventScroll: true })
})
}
}, [selectedCount])
function clearSelection() {
if (!busy) onClear?.()
}
if (selectedCount <= 0) return null
const context = { count: selectedCount, busy, clear: clearSelection }
return (
<div
{...rootProps}
ref={element}
role="region"
aria-label={label}
aria-busy={busy || undefined}
data-slot="bulk-actions"
className={twMerge(BASE_CLASSES, className)}
>
<span
role="status"
aria-live="polite"
aria-atomic="true"
data-slot="bulk-actions-summary"
className="mr-auto text-sm font-medium tabular-nums"
>
{typeof summary === 'function'
? summary(context)
: (summary ?? `${selectedCount} selected`)}
</span>
{typeof children === 'function' ? children(context) : children}
<button
type="button"
disabled={busy}
data-slot="bulk-actions-clear"
className="min-h-9 cursor-pointer rounded-md px-3 text-sm font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-950 disabled:cursor-not-allowed disabled:opacity-50 dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-white dark:focus-visible:outline-white"
onClick={clearSelection}
>
{clearLabel}
</button>
</div>
)
})
export default BulkActions
Svelte
<script>
import { tick } from "svelte";
import { twMerge } from "tailwind-merge";
const BASE_CLASSES =
"flex min-h-12 w-full flex-wrap items-center gap-3 rounded-lg border border-gray-200 bg-white px-3 py-2 text-gray-950 shadow-sm dark:border-gray-800 dark:bg-gray-950 dark:text-white";
let {
count = 0,
label = "Bulk actions",
busy = false,
clearLabel = "Clear selection",
summary,
children,
onclear,
class: className,
"data-slot": _dataSlot,
...rootProps
} = $props();
let element = $state();
let selectedCount = $derived(Math.max(0, Math.trunc(Number(count) || 0)));
let previousCount = 0;
function focusTarget(root) {
return root?.querySelector?.("[data-bulk-actions-focus]");
}
function clearSelection() {
if (!busy) onclear?.();
}
$effect.pre(() => {
const nextCount = selectedCount;
if (previousCount > 0 && nextCount <= 0) {
const rootElement = element;
const root = rootElement?.getRootNode?.() ?? document;
const activeElement = root.activeElement ?? document.activeElement;
const shouldRestore = rootElement?.contains(activeElement);
tick().then(() => {
if (shouldRestore) focusTarget(root)?.focus?.({ preventScroll: true });
});
}
previousCount = nextCount;
});
export function clear() {
clearSelection();
}
export function getElement() {
return element;
}
</script>
{#if selectedCount > 0}
<div
{...rootProps}
bind:this={element}
role="region"
aria-label={label}
aria-busy={busy || undefined}
data-slot="bulk-actions"
class={twMerge(BASE_CLASSES, className)}
>
<span
role="status"
aria-live="polite"
aria-atomic="true"
data-slot="bulk-actions-summary"
class="mr-auto text-sm font-medium tabular-nums"
>
{#if summary}
{@render summary({ count: selectedCount, busy, clear: clearSelection })}
{:else}
{selectedCount} selected
{/if}
</span>
{@render children?.({ count: selectedCount, busy, clear: clearSelection })}
<button
type="button"
disabled={busy}
data-slot="bulk-actions-clear"
class="min-h-9 cursor-pointer rounded-md px-3 text-sm font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-950 disabled:cursor-not-allowed disabled:opacity-50 dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-white dark:focus-visible:outline-white"
onclick={clearSelection}
>
{clearLabel}
</button>
</div>
{/if}
Related components
- DataTable — owns server results and page-scoped selection around the action region.
- Checkbox — provides the page or row selection controls.
- Menu — keeps secondary bulk commands accessible without an action schema.
- Dialog — confirms destructive operations against the selected set.
- Row Actions — groups commands and destinations for one record.
- Filter Bar — changes the result set without owning its selection actions.