Skip to content
txbrendan/ui

Suggestion

beta

An 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 sync
  • Mark ClarkeBack from 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. 1. Add the registry to components.json, once per app. Without it, the shadcn CLI looks up @txbrendan in a public list that txbrendan/ui does not control.

    npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json"
  2. 2. Add the theme, once per app. It replaces your color tokens with ones checked for contrast.

    npx shadcn@latest add @txbrendan/theme
  3. 3. 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

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>
  );
}