PixelDosa UI

AI Action Toolbar

A keyboard-navigable toolbar of the standard post-generation actions — apply, explain, retry, regenerate, undo, report — with a three-level visual hierarchy that keeps the recommended action dominant.

Three of the five flagged invoices share the same billing contact, which is the most likely cause of the duplicate-payment warning.

Installation

bash
npx shadcn@latest add @pixeldosa/ai-action-toolbar

Requires `cn` at `@/lib/utils` and `class-variance-authority`. No icon set is bundled — pass your own `size-4` icons via each action's optional `icon` prop, matching how Smart Field and Selection Actions already take icons. Exactly one action should carry `intent="primary"`; the component does not enforce this, because a confirmation-only row (a lone quiet Undo) legitimately has none.

Usage

"use client";

import * as React from "react";
import { AIActionToolbar, type AIAction } from "@/components/ui/ai-action-toolbar";

export function SummaryResult({ summary }: { summary: string }) {
  const [regenerating, setRegenerating] = React.useState(false);

  const actions: AIAction[] = [
    { id: "apply", label: "Apply", intent: "primary", icon: <CheckIcon /> },
    { id: "regenerate", label: "Regenerate", busy: regenerating, icon: <RetryIcon /> },
    { id: "explain", label: "Explain", intent: "quiet" },
    { id: "report", label: "Report", intent: "quiet" },
  ];

  return (
    <>
      <p>{summary}</p>
      <AIActionToolbar actions={actions} onAction={handleAction} label="Summary actions" />
    </>
  );
}

Exactly one action should carry intent="primary" — the recommended decision. The component doesn't enforce this, because a confirmation-only row (a lone quiet Undo) legitimately has none.

Features

  • The real ARIA toolbar pattern. The whole strip is a single tab stop; ←/→, Home and End move within it and skip disabled actions. Most shipped versions of this pattern rely on natural tab order instead, which costs a keyboard user one Tab press per action just to get past the toolbar.
  • Three levels of weight, no colour. Filled → bordered → underline-only, so the recommended action stays dominant without introducing a hue the token set doesn't have.
  • Regenerate is deliberately not primary. Weighting it below Apply is a product stance: read the result rather than reflexively rerolling it.
  • Busy state stays truthful. busy is a per-action prop owned by you, so it reflects your real in-flight request instead of a second copy that can drift. The button stays mounted and focusable while busy and sets aria-busy, rather than being swapped out for a spinner.
  • One action at a time. While any action is busy the rest disable — two concurrent actions on one result is never a coherent request.
  • Renders nothing when it has nothing — no actions and no message produces no empty shell.

Variants

The full review strip: one primary action, one secondary, and two quiet meta actions.

Try the arrow keys — the strip is one tab stop.

Intents

primary is the recommended decision, secondary a reversible alternative, quiet a meta action that doesn't change the result.

Try the arrow keys — the strip is one tab stop.

With a leading message

This is how a confirmation row is expressed — the same component as the decision row, rather than a second one-off treatment. It's also the shape Smart Field and Diff Accept both render after applying.

Changes applied.
Try the arrow keys — the strip is one tab stop.

Busy and disabled

While one action is busy the others disable automatically. The busy button keeps its label and stays focusable.

Try the arrow keys — the strip is one tab stop.

Sizes

sm is for dense contexts — a per-row toolbar in a table, for instance.

Try the arrow keys — the strip is one tab stop.

Examples

An error row — the toolbar doesn't own error state, so render the message yourself and offer Retry as an action. This is exactly what Smart Field and Selection Actions already do:

<AIActionToolbar
  message={error}
  label="Error actions"
  actions={[{ id: "retry", label: "Retry", intent: "quiet" }]}
  onAction={() => rerun()}
/>

A dense per-row toolbar, where the result being acted on is one row of many:

<AIActionToolbar
  size="sm"
  label={`Actions for ${row.name}`}
  actions={[
    { id: "apply", label: "Apply", intent: "primary", busy: row.status === "applying" },
    { id: "skip", label: "Skip", intent: "quiet" },
  ]}
  onAction={(id) => resolveRow(row.id, id)}
/>

Give each row's toolbar a distinct label — "Actions" repeated forty times down a table is useless to a screen reader user.

Gating an action until the user has done something first:

const actions: AIAction[] = [
  { id: "apply", label: "Apply", intent: "primary", disabled: !hasReviewed },
  { id: "explain", label: "Explain", intent: "quiet" },
];

Disabled actions are skipped by arrow-key navigation, so a disabled Apply never traps focus between the other two.

Props

PropTypeDefaultDescription
actionsAIAction[]—Required. Rendered in array order.
onAction(actionId: string) => void—Required. Fires with the activated action's id.
messageReact.ReactNode—Leading status text, shown before the actions.
labelstring"Result actions"Accessible name for the toolbar. Make it distinct when several appear on one page.
size"sm" | "default""default"

AIAction is { id: string; label: string; icon?: React.ReactNode; intent?: "primary" | "secondary" | "quiet"; disabled?: boolean; busy?: boolean }.

All other props forward to the wrapping <div>.

Accessibility

  • A real role="toolbar" implementing the ARIA APG pattern: roving tabindex, so the strip is one tab stop rather than one per action. ←/→ wrap at both ends, Home and End jump to the ends, and disabled actions are skipped throughout.
  • The tab stop is tracked by action id, not index, and falls back to the first enabled action if the focused one becomes disabled — so the toolbar can never end up with no tab stop and become keyboard-unreachable.
  • Busy actions set aria-busy and stay mounted and focusable. The label never changes mid-interaction, so the accessible name is stable.
  • Every action is a real <button> with a visible focus ring in both themes.
  • Labels are always visible — there is no icon-only mode, because "what does this arrow do?" is precisely the ambiguity this component exists to remove.
  • Under prefers-reduced-motion the colour transitions become instant. The busy spinner keeps rotating deliberately: removing it would leave a busy state with no visible indication at all.

When not to use

A floating toolbar triggered by a text selection is Selection Actions. A text diff needing per-hunk accept/reject is Diff Accept. A single whole-field proposal with confidence and provenance is Smart Field. This is the shared control strip those patterns compose — not a replacement for any of them.

why it's built this way

Engineering Notes

An extraction, not new design: three already-shipped components had independently hand-rolled the same control strip. Smart Field renders Retry, Reject/Accept and an 'Value accepted. Undo' row; Diff Accept renders 'Changes applied. Undo' and a Regenerate-on-conflict button; Selection Actions renders Retry and Dismiss. Across those seven call sites exactly three button treatments recur verbatim — filled primary, bordered secondary, underline quiet — along with a character-for-character duplicated spinner SVG and a duplicated message+Undo confirmation row. Those three treatments became the CVA `intent` variants rather than being re-derived a fourth time. Two deliberate boundaries: (1) `busy` is a per-action prop rather than internal state, because every existing call site already holds the request and its AbortController, so owning a second copy here would let the toolbar's idea of 'in flight' drift from the caller's; (2) the undo *timer* stays with the consumer — Smart Field and Diff Accept both already run it inside their own status state machines, and absorbing it here would fight those machines rather than simplify them, so the toolbar renders the undo affordance while the consumer owns when it exists. The roving tabindex is kept as an action id rather than an index so the tab stop survives the actions array changing between renders, and it falls back to the first enabled action whenever the focused one becomes disabled — otherwise a toolbar whose focused action just disabled would have no tab stop at all and become unreachable by keyboard.

how it moves, and when it doesn't

Motion Notes

Deliberately minimal, and the restraint is the decision. Colour-only transitions on hover and focus at `--pd-duration-instant` (100ms) with `--pd-ease-standard`, matching every other control in the system. There is no entrance animation, on purpose: this toolbar is always revealed by a container that already animates — Smart Field's proposal panel owns a grid-template-rows expand, Selection Actions owns a fade-and-scale — so a second entrance here would double-animate the same appearance. That mirrors Confidence Meter's rule that motion belongs to what wraps a primitive, not the primitive itself. The one continuous animation is the busy spinner, which is honest: it maps to a real in-flight request and stops when that request settles. Under prefers-reduced-motion the colour transitions drop to instant via `motion-reduce:transition-none`, while the spinner deliberately keeps rotating — removing it would leave a busy state with no visible indication at all, and the reduced-motion contract is a fallback that preserves legibility, not a removal.