Action Bar
A floating bulk-action bar that springs in on selection, rolls its count and confirms in place.
<script setup lang="ts">
import type { ActionBarAction } from '@/components/ui/action-bar'
import { ActionBar } from '@/components/ui/action-bar'
import { Archive, FileArchive, FileImage, FileSpreadsheet, FileText, FolderInput, RotateCcw, Trash2 } from 'lucide-vue-next'
import { computed, ref } from 'vue'
const initial = [
{ id: 'brief', name: 'Q4 product brief.pdf', size: '2.4 MB', icon: FileText },
{ id: 'hero', name: 'hero-shot@2x.png', size: '5.1 MB', icon: FileImage },
{ id: 'budget', name: 'Budget 2027.xlsx', size: '840 KB', icon: FileSpreadsheet },
{ id: 'notes', name: 'Interview notes.md', size: '12 KB', icon: FileText },
{ id: 'assets', name: 'brand-assets.zip', size: '48 MB', icon: FileArchive },
{ id: 'cover', name: 'cover-draft.jpg', size: '1.9 MB', icon: FileImage },
]
const files = ref([...initial])
const selected = ref<string[]>(['hero', 'cover'])
const status = ref('')
const allSelected = computed(() => files.value.length > 0 && selected.value.length === files.value.length)
const actions = computed<ActionBarAction[]>(() => [
{ id: 'move', label: 'Move', icon: FolderInput },
{ id: 'archive', label: 'Archive', icon: Archive },
{
id: 'delete',
label: 'Delete',
icon: Trash2,
destructive: true,
confirm: `Delete ${selected.value.length} ${selected.value.length === 1 ? 'file' : 'files'}?`,
},
])
function toggle(id: string) {
selected.value = selected.value.includes(id) ? selected.value.filter(s => s !== id) : [...selected.value, id]
}
function toggleAll() {
selected.value = allSelected.value ? [] : files.value.map(f => f.id)
}
function onAction(id: string) {
const n = selected.value.length
if (id === 'move') {
status.value = `Moved ${n} to “Shared”`
}
else {
files.value = files.value.filter(f => !selected.value.includes(f.id))
status.value = `${id === 'delete' ? 'Deleted' : 'Archived'} ${n} ${n === 1 ? 'file' : 'files'}`
}
selected.value = []
}
function reset() {
files.value = [...initial]
selected.value = []
status.value = ''
}
</script>
<template>
<div class="relative w-full max-w-lg overflow-hidden rounded-2xl border bg-card pb-20 text-card-foreground shadow-sm">
<div class="flex h-12 items-center gap-3 border-b px-4 text-sm">
<input
type="checkbox"
class="size-4 cursor-pointer accent-foreground"
aria-label="Select all files"
:checked="allSelected"
:indeterminate="selected.length > 0 && !allSelected"
:disabled="!files.length"
@change="toggleAll"
>
<span class="font-medium">Files</span>
<span class="ml-auto truncate text-xs text-muted-foreground">{{ status || `${files.length} items` }}</span>
<button
v-if="files.length < initial.length"
type="button"
class="inline-flex cursor-pointer items-center gap-1 rounded-md px-1.5 py-1 text-xs text-muted-foreground outline-none hover:bg-muted hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
@click="reset"
>
<RotateCcw class="size-3" /> Reset
</button>
</div>
<ul class="m-0 list-none p-1.5">
<li v-for="file in files" :key="file.id">
<label
:class="[
'flex h-11 cursor-pointer items-center gap-3 rounded-lg px-2.5 text-sm transition-colors',
selected.includes(file.id) ? 'bg-accent' : 'hover:bg-muted/60',
]"
>
<input
type="checkbox"
class="size-4 cursor-pointer accent-foreground"
:checked="selected.includes(file.id)"
@change="toggle(file.id)"
>
<component :is="file.icon" class="size-4 text-muted-foreground" />
<span class="min-w-0 flex-1 truncate">{{ file.name }}</span>
<span class="text-xs tabular-nums text-muted-foreground">{{ file.size }}</span>
</label>
</li>
<li v-if="!files.length" class="py-12 text-center text-sm text-muted-foreground">
Nothing left here.
</li>
</ul>
<ActionBar
strategy="absolute"
noun="selected"
:count="selected.length"
:actions="actions"
@action="onAction"
@clear="selected = []"
/>
</div>
</template>Installation
pnpm dlx shadcn-vue@latest add https://vuelix.dev/r/action-bar.jsonManual installation
Install the dependencies:
npm install motion-vThen copy these files into components/ui/action-bar/ (plus lib/utils.ts if you don't have it):
<script setup lang="ts">
import type { Component, HTMLAttributes } from 'vue'
import { AnimatePresence, motion, useDomRef, useReducedMotion } from 'motion-v'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { cn } from '@/lib/utils'
export interface ActionBarAction {
id: string
label: string
icon?: Component
/** Red styling; use for actions that lose data. */
destructive?: boolean
/** Ask first. `true` or the question to show, e.g. `'Delete 3 files?'`. */
confirm?: boolean | string
/** Label of the confirming button (defaults to `label`). */
confirmLabel?: string
disabled?: boolean
}
const props = withDefaults(defineProps<{
/** Number of selected items. The bar shows while it's above 0. */
count: number
actions: ActionBarAction[]
/** Word after the count. */
noun?: string
/** `fixed` floats over the viewport; `absolute` floats over the nearest positioned parent. */
strategy?: 'fixed' | 'absolute'
/** Accessible name of the toolbar. */
label?: string
class?: HTMLAttributes['class']
}>(), {
noun: 'selected',
strategy: 'fixed',
label: 'Bulk actions',
})
const emit = defineEmits<{
/** An action ran (after confirmation, when it asks for one). */
action: [id: string]
/** The clear button or Escape was pressed. */
clear: []
}>()
const reduced = useReducedMotion()
const visible = computed(() => props.count > 0)
const revealed = ref(false)
const confirming = ref<ActionBarAction | null>(null)
const focusKey = ref<string>('clear')
const toolbar = useDomRef()
const lift = { type: 'spring', stiffness: 420, damping: 30 } as const
const grow = { type: 'spring', stiffness: 360, damping: 30 } as const
const snap = { type: 'spring', stiffness: 600, damping: 34 } as const
// --- Reveal: the bar lands compact, then grows to show its actions --------
// Runs on the client only (watch + onMounted), so SSR and hydration agree.
let revealTimer: ReturnType<typeof setTimeout> | undefined
function sync(isVisible: boolean) {
clearTimeout(revealTimer)
confirming.value = null
if (!isVisible) {
revealed.value = false
return
}
if (reduced.value)
revealed.value = true
else
revealTimer = setTimeout(() => (revealed.value = true), 160)
}
watch(visible, sync)
onMounted(() => sync(visible.value))
onBeforeUnmount(() => clearTimeout(revealTimer))
// --- Rolling count ---------------------------------------------------------
const direction = ref(1)
watch(() => props.count, (next, prev) => {
if (next && prev)
direction.value = next > prev ? 1 : -1
})
const digits = computed(() => {
const s = String(Math.max(0, props.count))
// Key each digit by its place value so only the digits that change roll.
return s.split('').map((d, i) => ({ d, key: `${s.length - i}:${d}` }))
})
const roll = {
enter: (dir: number) => ({ y: reduced.value ? 0 : dir * 14, opacity: 0 }),
center: { y: 0, opacity: 1 },
exit: (dir: number) => ({ y: reduced.value ? 0 : dir * -14, opacity: 0 }),
}
// --- Measured shell --------------------------------------------------------
const size = ref<{ width: number, height: number } | null>(null)
let observer: ResizeObserver | undefined
let observed: HTMLElement | null = null
// Function refs run on every render, so only (re)subscribe when the node changes.
function track(el: unknown) {
const node = ((el as { $el?: unknown } | null)?.$el ?? el) as HTMLElement | null
if (!(node instanceof HTMLElement) || node === observed)
return
observed = node
observer?.disconnect()
const measure = () => {
const { offsetWidth: width, offsetHeight: height } = node
if (size.value?.width !== width || size.value?.height !== height)
size.value = { width, height }
}
measure()
if (typeof ResizeObserver !== 'undefined') {
observer = new ResizeObserver(measure)
observer.observe(node)
}
}
onBeforeUnmount(() => observer?.disconnect())
// --- Actions + confirm step ------------------------------------------------
function question(action: ActionBarAction) {
if (typeof action.confirm === 'string')
return action.confirm
return `${action.label} ${props.count} ${props.count === 1 ? 'item' : 'items'}?`
}
function focus(key: string) {
focusKey.value = key
nextTick(() => (toolbar.value as HTMLElement | null)?.querySelector<HTMLElement>(`[data-key="${CSS.escape(key)}"]`)?.focus())
}
function run(action: ActionBarAction) {
if (action.disabled)
return
if (action.confirm) {
confirming.value = action
focus('c:cancel')
return
}
emit('action', action.id)
}
function cancel() {
const action = confirming.value
confirming.value = null
if (action)
focus(`a:${action.id}`)
}
function confirm() {
const action = confirming.value
confirming.value = null
if (action) {
focus(`a:${action.id}`)
emit('action', action.id)
}
}
// --- Roving tabindex -------------------------------------------------------
const keys = computed(() => {
if (confirming.value)
return ['clear', 'c:cancel', 'c:confirm']
return ['clear', ...(revealed.value ? props.actions.filter(a => !a.disabled).map(a => `a:${a.id}`) : [])]
})
function onKeydown(e: KeyboardEvent) {
const list = keys.value
const at = Math.max(0, list.indexOf(focusKey.value))
let next: string | undefined
if (e.key === 'ArrowRight')
next = list[(at + 1) % list.length]
else if (e.key === 'ArrowLeft')
next = list[(at - 1 + list.length) % list.length]
else if (e.key === 'Home')
next = list[0]
else if (e.key === 'End')
next = list.at(-1)
else if (e.key === 'Escape') {
e.preventDefault()
if (confirming.value)
cancel()
else
emit('clear')
return
}
if (next === undefined)
return
e.preventDefault()
focus(next)
}
const tab = (key: string) => (key === focusKey.value || (!keys.value.includes(focusKey.value) && key === 'clear') ? 0 : -1)
const button = 'inline-flex h-9 shrink-0 cursor-pointer items-center gap-2 rounded-xl px-3 text-sm font-medium outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0'
</script>
<template>
<AnimatePresence>
<motion.div
v-if="visible"
key="bar"
:class="cn(
'inset-x-0 z-50 mx-auto w-fit',
props.strategy === 'fixed' ? 'fixed bottom-6' : 'absolute bottom-4',
props.class,
)"
:initial="{ opacity: 0, y: reduced ? 0 : 32, scale: reduced ? 1 : 0.9 }"
:animate="{ opacity: 1, y: 0, scale: 1 }"
:exit="{ opacity: 0, y: reduced ? 0 : 24, scale: reduced ? 1 : 0.95, transition: { duration: 0.18 } }"
:transition="lift"
>
<motion.div
ref="toolbar"
role="toolbar"
aria-orientation="horizontal"
:aria-label="props.label"
class="overflow-hidden rounded-2xl border bg-popover text-popover-foreground shadow-xl"
:initial="false"
:animate="size ?? {}"
:transition="reduced ? { duration: 0 } : grow"
@keydown="onKeydown"
>
<div :ref="track" class="relative flex w-max items-center gap-1 p-1.5">
<button
type="button"
data-key="clear"
:tabindex="tab('clear')"
aria-label="Clear selection"
:class="cn(button, 'size-9 justify-center px-0 text-muted-foreground hover:bg-accent hover:text-foreground', confirming && 'max-sm:hidden')"
@focus="focusKey = 'clear'"
@click="emit('clear')"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12" /></svg>
</button>
<span :class="cn('flex items-center gap-1.5 pr-2 text-sm font-medium', confirming && 'max-sm:hidden')">
<span class="relative inline-flex h-5 overflow-hidden tabular-nums" aria-hidden="true">
<AnimatePresence mode="popLayout" :initial="false" :custom="direction">
<motion.span
v-for="digit in digits"
:key="digit.key"
class="inline-block"
:variants="roll"
initial="enter"
animate="center"
exit="exit"
:transition="snap"
>{{ digit.d }}</motion.span>
</AnimatePresence>
</span>
<span class="text-muted-foreground">{{ props.noun }}</span>
<span class="sr-only" aria-live="polite">{{ props.count }} {{ props.noun }}</span>
</span>
<AnimatePresence mode="popLayout" :initial="false">
<motion.div
v-if="revealed && !confirming"
key="actions"
class="flex items-center gap-1 border-l pl-1.5"
:initial="{ opacity: 0, x: reduced ? 0 : -8, filter: reduced ? 'blur(0px)' : 'blur(4px)' }"
:animate="{ opacity: 1, x: 0, filter: 'blur(0px)' }"
:exit="{ opacity: 0, filter: reduced ? 'blur(0px)' : 'blur(4px)', transition: { duration: 0.12 } }"
:transition="grow"
>
<button
v-for="action in props.actions"
:key="action.id"
type="button"
:data-key="`a:${action.id}`"
:tabindex="tab(`a:${action.id}`)"
:disabled="action.disabled"
:class="cn(
button,
action.destructive
? 'text-destructive hover:bg-destructive/10'
: 'text-foreground hover:bg-accent',
)"
@focus="focusKey = `a:${action.id}`"
@click="run(action)"
>
<component :is="action.icon" v-if="action.icon" aria-hidden="true" />
<span :class="action.icon && 'max-sm:sr-only'">{{ action.label }}</span>
</button>
</motion.div>
<motion.div
v-else-if="confirming"
key="confirm"
class="flex items-center gap-1 border-l pl-3 max-sm:border-l-0 max-sm:pl-1.5"
:initial="{ opacity: 0, y: reduced ? 0 : 10, filter: reduced ? 'blur(0px)' : 'blur(4px)' }"
:animate="{ opacity: 1, y: 0, filter: 'blur(0px)' }"
:exit="{ opacity: 0, y: reduced ? 0 : -10, filter: reduced ? 'blur(0px)' : 'blur(4px)', transition: { duration: 0.12 } }"
:transition="grow"
>
<span class="mr-2 text-sm font-medium" role="alert">{{ question(confirming) }}</span>
<button
type="button"
data-key="c:cancel"
:tabindex="tab('c:cancel')"
:class="cn(button, 'text-foreground hover:bg-accent')"
@focus="focusKey = 'c:cancel'"
@click="cancel"
>
Cancel
</button>
<button
type="button"
data-key="c:confirm"
:tabindex="tab('c:confirm')"
:class="cn(button, confirming.destructive ? 'bg-destructive text-white hover:bg-destructive/90' : 'bg-primary text-primary-foreground hover:bg-primary/90')"
@focus="focusKey = 'c:confirm'"
@click="confirm"
>
<component :is="confirming.icon" v-if="confirming.icon" aria-hidden="true" />
{{ confirming.confirmLabel ?? confirming.label }}
</button>
</motion.div>
</AnimatePresence>
</div>
</motion.div>
</motion.div>
</AnimatePresence>
</template>export { default as ActionBar } from './ActionBar.vue'
export type { ActionBarAction } from './ActionBar.vue'Usage
import { ActionBar } from '@/components/ui/action-bar'<ActionBar
:count="selected.length"
:actions="[
{ id: 'archive', label: 'Archive', icon: Archive },
{ id: 'delete', label: 'Delete', icon: Trash2, destructive: true, confirm: true },
]"
@action="run"
@clear="selected = []"
/>The bar lands compact with just the count, then springs wider to reveal its actions. Changing the selection rolls only the digits that change. An action with confirm swaps the actions for a question and a confirm button in place; the bar resizes to fit each step.
API
| Prop | Type | Default | Description |
|---|---|---|---|
count | number | — | Number of selected items. The bar shows while it's above 0. |
actions | ActionBarAction[] | — | Actions (see below). |
noun | string | 'selected' | Word after the count. |
strategy | 'fixed' | 'absolute' | 'fixed' | Float over the viewport, or over the nearest positioned parent. |
label | string | 'Bulk actions' | Accessible name of the toolbar. |
ActionBarAction: { id, label, icon?, destructive?, confirm?: boolean | string, confirmLabel?, disabled? }. A string confirm is the question shown, e.g. 'Delete 3 files?'.
| Event | Payload | Description |
|---|---|---|
action | id: string | An action ran (after confirmation when it asks for one). |
clear | — | The clear button or Escape was pressed. |
The bar is a toolbar with a roving tab stop: ← / → / Home / End move between buttons, Escape backs out of a confirmation or clears the selection, and the count is announced politely.