Skip to content
Tier

Decision bar

Pro

The decision for the item on screen, pinned where a thumb reaches it: put it in AppLayout's bottomBar, above a phone's tab bar. It names the item it decides (title), so a scroll can't leave the person deciding something they no longer see, and shows the app's actions, none of them focused or drawn as the default.

When itemId changes, every action is locked for changeGuard milliseconds (half a second unless set), with the new title as the reason, so a tap meant for the last item can't land on the next one; the lock is a safety guard, not motion, and reduced motion doesn't shorten it. The new title is announced once, from the page's shared live region; nothing is announced when the bar first appears. A destructive action carries the app's words for what can and can't be undone, printed under the bar and tied to its button. An action the person can't take says why (locked), and locked on the bar locks them all, for a decision that can't be made here. Focus is never moved, except to the bar's own title when the button that had it goes away (new actions for a new item).

Markdown
Version 5.4.0· other versions

This page is for 5.4.0, the current release. An earlier release’s page is the Markdown that release published, so the docs match the version you have installed (candor-lock.json records it).

Not in the docs of 1.3.0, 1.4.0, 1.5.0, 1.5.1, 1.6.0, 1.7.0, 1.8.0, 1.8.1, 1.9.0, 1.9.1, 2.0.0, 2.1.0, 2.2.0, 2.3.0, 2.4.0, 2.5.0, 2.5.1, 2.6.0, 2.6.1, 2.6.2, 2.7.0, 2.8.0, 2.9.0, 2.10.0, 3.0.0, 4.0.0, 4.0.1, 4.0.2, 4.1.0, 4.2.0, 4.3.0, 4.4.0, 4.5.0, 5.0.0, 5.1.0 and 5.2.0: the page came later, or had another name then.

Docs pages weren’t kept for 1.0.0, 1.0.1, 1.1.0, 1.1.1, 1.2.0 and 1.2.1: they are kept from 1.3.0 on, and before that only llms.txt was published.

Preview

Loading the preview

Install

With the Candor CLI

First time? Get started sets up the registry and theme. Or with shadcn directly: npx shadcn@latest add @candor/decision-bar

Brings in: bidi, button, locale, locked, tokens, use-announcer, use-focus-within, utils

Usage

TSX · The example above
"use client"

import * as React from "react"
import { Bot, Inbox, MessagesSquare, Settings } from "lucide-react"
import { AppLayout } from "@/components/candor/app-layout"
import { DecisionBar } from "@/components/candor/decision-bar"
import { TabBar } from "@/components/candor/tab-bar"

const reports = [
  { id: "r12", title: "Weekly report 12", summary: "The agent drafted the weekly report from 14 runs. Two charts were redrawn after the data refresh." },
  { id: "r13", title: "Weekly report 13", summary: "The agent drafted the weekly report from 9 runs. One section is shorter than usual: 3 runs failed." },
  { id: "q4", title: "Question 4 from the research agent", summary: "Should the summary include last quarter's figures, or only this quarter's?" },
]

// On a phone, the decision for the item on screen sits above the tab bar; Next moves to another item, and the bar
// locks its actions for half a second as the title changes, so a tap meant for the last item can't land on this one.
export default function Example() {
  const [at, setAt] = React.useState(0)
  const [done, setDone] = React.useState<string | null>(null)
  const item = reports[at]!
  return (
    <div className="flex flex-col gap-2">
      <div className="h-[460px] w-[360px] max-w-full overflow-hidden rounded-lg border">
        <AppLayout
          // A phone-sized preview: the tab bar shows below 640 px, so the bar sits above it.
          defaultWidth={360}
          sidebar={<nav aria-label="Places" className="p-3 text-sm">Places</nav>}
          tabBar={
            <TabBar
              destinations={[
                { id: "threads", label: "Threads", icon: MessagesSquare },
                { id: "inbox", label: "Inbox", icon: Inbox, badge: reports.length },
                { id: "agents", label: "Agents", icon: Bot },
                { id: "settings", label: "Settings", icon: Settings },
              ]}
              activeId="inbox"
            />
          }
          bottomBar={
            <DecisionBar
              itemId={item.id}
              title={item.title}
              actions={
                item.id === "q4"
                  ? [{ id: "answer", label: "Answer", locked: "Answer it in the thread: it needs your words." }]
                  : [
                      { id: "accept", label: "Accept" },
                      { id: "return", label: "Send back" },
                      { id: "discard", label: "Discard", destructive: true, undoable: "The draft can be restored for 30 days; the runs it used aren't undone." },
                    ]
              }
              onAction={(action, itemId) => setDone(`${action} on ${itemId}`)}
            />
          }
        >
          <article className="flex flex-col gap-2 overflow-y-auto p-4 text-sm">
            <h3 className="font-semibold">{item.title}</h3>
            <p>{item.summary}</p>
            <button type="button" className="self-start rounded-md border px-2 py-1 text-xs" onClick={() => setAt((at + 1) % reports.length)}>
              Next item
            </button>
          </article>
        </AppLayout>
      </div>
      <p className="text-xs text-muted-foreground" aria-live="polite">
        {done ? `Last decision: ${done}` : "No decision yet."}
      </p>
    </div>
  )
}

<DecisionBar>

PropTypeDefaultDescription
itemIdrequiredstring—The item the bar decides: when it changes, the actions lock for `changeGuard` and the new title is announced.
titlerequiredstring—The item's name as the person sees it on the page, drawn as text (a model's title is safe here).
actionsrequiredDecisionBarAction[]—The app's actions for this item, in the order drawn. None is pre-selected or styled as the default.
onActionrequired(actionId: string, itemId: string) => void—Called with the action's id and the `itemId` it was taken on, so the app can check it is the item it expects.
changeGuardnumber | undefineddecisionBarChangeGuardHow long, in milliseconds, the actions stay locked after `itemId` changes. Defaults to 500 (`decisionBarChangeGuard`); 0 turns the guard off. Not shortened under reduced motion: it guards a decision, it isn't an animation.
lockedstring | null | undefined—Why no decision can be made here (a permission, a state the item is in). Given, every action is locked with it.
pendingstring | null | undefined—The id of the action being recorded: its button is busy, and it and the others are locked until it is done.
safeAreaboolean | undefined—Pad the bar's foot by `env(safe-area-inset-bottom)`, for the foot of a phone's screen. Use it when nothing sits under the bar to clear that space: an AppLayout with `bottomBar` and no `tabBar` (the tab bar pads its own foot), or a bar the app places at the foot itself. Left out, the bar draws as it did and adds no padding. It is the bar's own bottom padding (`py-2`'s, from the theme's spacing) plus the inset, so a `py-*` or `pb-*` in `className` replaces it and the inset with it.
classNamestring | undefined—Classes for the bar, merged with Candor's.

Values

decisionBarChangeGuard

How long, in milliseconds, DecisionBar's actions stay locked after the item changes, unless changeGuard says.

500

Strings

The fixed words, in English by default. Change any of them under decisionBar in <LocaleProvider strings={{ … }}>; the table is exported as decisionBarStrings.

KeyEnglish
regionDecision on {title}
decidingDeciding {title}
changedNow deciding {title}
guardThe item just changed to {title}. Check it before you decide.
recordingRecording your decision
undoable{action}: {undoable}

Types

DecisionBarAction
One of DecisionBar's actions: the app's words for it, and whether it can be used now.
{ /** Stable for this action; `onAction` is called with it. */ id: string; /** The button's words, the app's ("Approve", "Send back"). */ label: string; /** Why the person can't take this action here, now. Given, the button stays focusable and says why (`Locked`). */ locked?: string | null | undefined; } & ({ /** * The action deletes, sends or spends something. It needs `undoable`: the bar prints those words under it, and * the button is drawn in the destructive tone, never as the default. */ destructive: true; /** What can and can't be undone after this action, in the app's words ("Can be reversed for 30 days; the email can't be unsent"). */ undoable: string; } | { /** Not destructive (the default). */ destructive?: false | undefined; /** Only for a destructive action. */ undoable?: undefined; })

Terms used here

Calm level
What Candor's motion does for a person whose operating system asks for reduced motion: the highest-precedence setting, above the app's own motion level (none, calm, subtle or expressive) and the brand file. A theme tunes how a change looks, never when it happens, so nothing depends on seeing the motion.

Every term is defined in the glossary (Markdown).