Decision bar
ProThe 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).
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
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
"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>
| Prop | Type | Default | Description |
|---|---|---|---|
| itemIdrequired | string | — | The item the bar decides: when it changes, the actions lock for `changeGuard` and the new title is announced. |
| titlerequired | string | — | The item's name as the person sees it on the page, drawn as text (a model's title is safe here). |
| actionsrequired | DecisionBarAction[] | — | 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. |
| changeGuard | number | undefined | decisionBarChangeGuard | How 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. |
| locked | string | null | undefined | — | Why no decision can be made here (a permission, a state the item is in). Given, every action is locked with it. |
| pending | string | null | undefined | — | The id of the action being recorded: its button is busy, and it and the others are locked until it is done. |
| safeArea | boolean | 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. |
| className | string | undefined | — | Classes for the bar, merged with Candor's. |
Values
decisionBarChangeGuardHow long, in milliseconds, DecisionBar's actions stay locked after the item changes, unless changeGuard says.
500Strings
The fixed words, in English by default. Change any of them under decisionBar in <LocaleProvider strings={{ … }}>; the table is exported as decisionBarStrings.
| Key | English |
|---|---|
| region | Decision on {title} |
| deciding | Deciding {title} |
| changed | Now deciding {title} |
| guard | The item just changed to {title}. Check it before you decide. |
| recording | Recording 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,subtleorexpressive) 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).