Skip to content

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.

ServiceBulkActions.vue
Hagfish can keep its graphic invoice treatment while Slipway Bridge keeps its compact operational menu. Both use the same semantic contract.

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.

Terminal
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

ServiceBulkActions.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

ServiceBulkActions.jsx
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

ServiceBulkActions.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

PurposeVueReactSvelte
Selected countcountcountcount
Accessible namelabellabellabel
Pending operationbusybusybusy
Clear copyclearLabelclearLabelclearLabel
Clear selection@clearonClearonclear
Actionsdefault slotchildrenchildren snippet
Count summary#summary slotsummarysummary snippet
Root stylingclassclassNameclass

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.

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-busy appears only while busy is 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

BulkActions.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

BulkActions.jsx
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

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}

  • 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.

All open source projects are released under the MIT License.