Suggestion
betaAn AI or rule-based suggestion shown in place and never applied on its own: a quiet chip on a row, or ghost words that would continue a field. Taken, it says so in place, with Undo.
Preview
Point at a chip to see why. Point at a row and press S to take its chip, or Esc to turn it down.
- Cal NashNot replied · Tuesday team syncHe came to the last three syncs
- Mark ClarkeBack from leaveEases back in after leave
- Joe PrattAvailable · Friday demo day
Last action: Nothing yet
Suggested: 13:05. Press Tab to take it, or Esc to turn it down.
Suggested: Thursday at 18:30. Press Tab to take it, or Esc to turn it down.
Install
1. Add the registry to
components.json, once per app. Without it, the shadcn CLI looks up@txbrendanin a public list that txbrendan/ui does not control.npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json"2. Add the theme, once per app. It replaces your color tokens with ones checked for contrast.
npx shadcn@latest add @txbrendan/theme3. Add the component.
npx shadcn@latest add @txbrendan/suggestion
Use it for
- Suggestion: a chip on a row with a next step, like 'Ask Cal Nash' or 'Start as reviewer'. Give it a reason, so hover shows why.
- SuggestionText: ghost words over a field, after what is typed, like ' at 13:05'. Tab takes them while keys is on.
- Say the ghost words in the field's description, like 'Suggested: 13:05. Press Tab to take it.' They are hidden from screen readers.
- Turn keys on for the focused row or field only, so one suggestion answers at a time.
- Give it onUndo when taking it changes something, so its record offers Undo for 8 seconds.
- When where it lands is off screen, show your own toast from onAccept. Suggestion shows no toast.
Avoid it for
- Applying a suggestion on its own. It waits for a press, a tap, its key, or Tab.
- A dashed edge around it. Dashes mark a place where something can be dropped.
- A chip for the row's main action. Use a Button.
- Ghost text in a field that already has a placeholder showing. Keep the mark on in an empty field, so the two can't be mixed up.
Props
- children
Required. Suggestion: its words. SuggestionText: the ghost words, a string. Spaces show as typed.
ReactNode
- reason
Suggestion only. Why it was suggested. The tooltip shows it, and it is the button's description.
string
- onAccept
Takes it. Return a promise to read busy until it lands. A failure turns it into Retry. Show a toast here when where it lands is off screen.
() => void | Promise<unknown>
- doneLabel
What it reads as once taken, and the record it leaves, like 'Asked Cal Nash'. Default its own words.
string
- onUndo
Takes it back, like clearing a prefill. With it, the record offers Undo while the window lasts.
() => void
- onCommit
The acceptance is final: the window ended, or it left the page mid-window. Return a promise, and a failure turns it into Retry.
() => void | Promise<unknown>
- say
What screen readers hear as it's taken. Default its doneLabel, or 'Accepted: ' and its words.
string
- failed
What it says when taking it didn't go through, like "Couldn't ask Mark Clarke". It stays on offer as Retry.
string
- toast
What a toast says once it's taken, only for what the suggestion can't show, like "Cal Nash added to Saturday". Needs a Toaster that called setToastSlot from tool-button. If the suggestion leaves mid-window, the toast keeps its Undo.
string
- onDismiss
Turns it down. A chip shows its × button while on offer, and Esc calls it while keys is on.
() => void
- keys
Listens for its keys: a chip's shortcut, Tab for ghost text in its field, and Esc for both. Turn it on for the focused row or field only.
boolean · default false
- shortcut
Suggestion only. One key that takes it while keys is on, like 'S'. The tooltip teaches it. Never Tab.
string
- dismissLabel
Suggestion only. Names the × button.
string · default "Dismiss suggestion"
- disabled
Suggestion only. Blocks taking it.
boolean · default false
- mark
SuggestionText only. Ends the words in the sparkles mark, a button that takes it. Keep it while the field is empty.
boolean · default true
- ref
Reaches the outer span.
Ref<HTMLSpanElement>
- className
Extra classes on the outer span. Later classes win.
string
| Prop | Type | Default | What it does |
|---|---|---|---|
| children | ReactNode | - | Required. Suggestion: its words. SuggestionText: the ghost words, a string. Spaces show as typed. |
| reason | string | - | Suggestion only. Why it was suggested. The tooltip shows it, and it is the button's description. |
| onAccept | () => void | Promise<unknown> | - | Takes it. Return a promise to read busy until it lands. A failure turns it into Retry. Show a toast here when where it lands is off screen. |
| doneLabel | string | - | What it reads as once taken, and the record it leaves, like 'Asked Cal Nash'. Default its own words. |
| onUndo | () => void | - | Takes it back, like clearing a prefill. With it, the record offers Undo while the window lasts. |
| onCommit | () => void | Promise<unknown> | - | The acceptance is final: the window ended, or it left the page mid-window. Return a promise, and a failure turns it into Retry. |
| say | string | - | What screen readers hear as it's taken. Default its doneLabel, or 'Accepted: ' and its words. |
| failed | string | - | What it says when taking it didn't go through, like "Couldn't ask Mark Clarke". It stays on offer as Retry. |
| toast | string | - | What a toast says once it's taken, only for what the suggestion can't show, like "Cal Nash added to Saturday". Needs a Toaster that called setToastSlot from tool-button. If the suggestion leaves mid-window, the toast keeps its Undo. |
| onDismiss | () => void | - | Turns it down. A chip shows its × button while on offer, and Esc calls it while keys is on. |
| keys | boolean | false | Listens for its keys: a chip's shortcut, Tab for ghost text in its field, and Esc for both. Turn it on for the focused row or field only. |
| shortcut | string | - | Suggestion only. One key that takes it while keys is on, like 'S'. The tooltip teaches it. Never Tab. |
| dismissLabel | string | "Dismiss suggestion" | Suggestion only. Names the × button. |
| disabled | boolean | false | Suggestion only. Blocks taking it. |
| mark | boolean | true | SuggestionText only. Ends the words in the sparkles mark, a button that takes it. Keep it while the field is empty. |
| ref | Ref<HTMLSpanElement> | - | Reaches the outer span. |
| className | string | - | Extra classes on the outer span. Later classes win. |
Accessibility
- A chip is a button named by its words. Its reason is the description. Tab moves past it, and Enter or Space takes it.
- Ghost words are hidden from screen readers, so the field's description must say them. The mark is a button named 'Accept suggestion:' and the words.
- Taken, the button is named by its record. While Undo is shown it says '… Press to undo.' After the window it stays focusable but can't be pressed.
- What happened is said once through the page's one polite live region: the record, 'Undone', or what failed. So it is still heard after the suggestion leaves its row or field.
- Keys are ignored while a modifier is held. A chip's keys are also ignored while someone types, or inside an open dialog or menu.
- Every suggestion carries the sparkles mark, so it never passes for the app's own words or a placeholder.
Examples
A chip on a row
import { useState } from "react";
function NextStep() {
const [shown, setShown] = useState(true);
if (!shown) return null;
return (
<Suggestion
reason="He came to the last three syncs"
doneLabel="Asked Cal Nash"
onAccept={() => fetch("/api/ask", { method: "POST" })}
onUndo={() => {}}
onDismiss={() => setShown(false)}
>
Ask Cal Nash
</Suggestion>
);
}Ghost text in an empty field
import { useState } from "react";
function MeetAt() {
const [value, setValue] = useState("");
const [focused, setFocused] = useState(false);
return (
<div className="relative">
<input
aria-label="Meet at"
aria-describedby="meet-hint"
value={value}
onChange={(event) => setValue(event.target.value)}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
className="h-9 w-full rounded-md border border-input bg-card px-3 text-sm"
/>
{value === "" ? (
<div className="pointer-events-none absolute inset-y-0 left-3 flex items-center text-sm">
<SuggestionText keys={focused} onAccept={() => setValue("13:05")}>
13:05
</SuggestionText>
</div>
) : null}
<p id="meet-hint" className="sr-only">Suggested: 13:05. Press Tab to take it.</p>
</div>
);
}