Skip to content

Editable text

Click-to-edit text: Enter or blur commits and Escape restores.

Status
Stable
Category
Inputs
Adoption
Not used yet
import { EditableText } from "@oration/canon/components/editable-text";
packages/canon/src/components/editable-text.tsx

Supplier since March 2024, paid by ACH on Net 30.

Remit-to email
Vendor ID
import { EditableText } from "@oration/canon/components/editable-text";import { toast } from "@oration/canon/components/toast";import { cn } from "@oration/canon/lib/utils";import * as React from "react";export function Hero() {    const [name, setName] = React.useState("Northwind Freight LLC");    const [email, setEmail] = React.useState("ap@northwindfreight.com");    const [vendorId, setVendorId] = React.useState("V-004417");    const rows = [        { label: "Remit-to email", value: email, set: setEmail, mono: false },        { label: "Vendor ID", value: vendorId, set: setVendorId, mono: true },    ];    return (        <div className="flex w-full max-w-lg flex-col gap-4 rounded-xl bg-card p-5 text-left shadow-border">            <div className="flex flex-col gap-1">                <div className="text-xl font-semibold tracking-[-0.015em] text-foreground">                    <EditableText                        label="Supplier name"                        value={name}                        onCommit={(next) => {                            if (!next) {                                toast.add({                                    type: "error",                                    title: "A supplier needs a name",                                });                                return;                            }                            setName(next);                            toast.add({                                type: "success",                                title: `Renamed to ${next}`,                            });                        }}                        className="-ml-1.5"                    />                </div>                <p className="text-13 text-muted-foreground">                    Supplier since March 2024, paid by ACH on Net 30.                </p>            </div>            <dl className="flex flex-col divide-y divide-border rounded-[10px] bg-muted/70 px-3">                {rows.map((row) => (                    <div                        key={row.label}                        className="grid grid-cols-[8rem_minmax(0,1fr)] items-center gap-3 py-1.5"                    >                        <dt className="text-13 text-muted-foreground">                            {row.label}                        </dt>                        <dd                            className={cn(                                "min-w-0 text-13",                                row.mono && "font-mono text-xs",                            )}                        >                            <EditableText                                label={row.label}                                value={row.value}                                onCommit={(next) => {                                    row.set(next);                                    toast.add({                                        title: `Updated ${row.label.toLowerCase()}`,                                        description: next || "Cleared",                                    });                                }}                                className="h-8 px-2"                                inputClassName="h-8 px-2"                            />                        </dd>                    </div>                ))}            </dl>        </div>    );}

Usage#

Editable text shows a value as plain text and turns it into an input when clicked, for renaming in place: a workflow name in the header, a ticket title, a supplier's legal name in its attribute list. Enter or clicking away commits, Escape restores, and there is no Save button. The mistake people make is styling it only through className. That styles the resting button; the input that appears on click inherits its type from the parent, so put the font size and weight on the wrapper, or pass them to inputClassName too, or the text jumps when editing starts.

When to use

  • To rename the thing a page is about, in its header: a workflow, a ticket, a supplier.
  • For short text attributes in a record's attribute list, one value per row.
  • For a description that people edit rarely, with multiline.
  • Where each change saves on its own and confirms with a toast or a quiet saved state.

When not to use

  • In a form with a Save button, where all fields commit together. Use Field
  • For a value with a fixed set of options, such as stage or tier. Use Select
  • For long-form text such as prompts or instructions. Use Prompt editor
  • For a value people copy more often than they change, such as an ID. Use Copy row

Every change is acknowledged

A commit saves right away, so tell people it happened: a toast with Renamed to Northwind Freight LLC, or an Undo when the change is easy to regret.

The Machine Mono Rule

Machine values such as vendor IDs are set in Geist Mono in both the resting and editing states; pass the class to className and inputClassName.

Anatomy#

  1. Display button. A full-width <button> with 8px corners and 6px side padding, showing the value truncated to one line (three with multiline).
  2. Placeholder. Shown in Faint Slate when the value is empty: Empty by default.
  3. Editor. An input, or a three-row textarea with multiline, on the White Plane with a 2px indigo ring at 50%. It opens with the text selected.

Examples#

Attribute list

One Editable text per row at 32px and 13px, with the attribute name as its label. Pass the same height and padding to inputClassName so the row doesn't move when it opens.

Legal name
Billing email
Payment terms
W-9 on file
import { EditableText } from "@oration/canon/components/editable-text";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function Attributes() {    const [values, setValues] = React.useState({        legal: "Orchard Street Produce Co.",        email: "billing@orchardstreet.com",        terms: "Net 45",        w9: "",    });    const rows = [        { key: "legal", label: "Legal name" },        { key: "email", label: "Billing email" },        { key: "terms", label: "Payment terms" },        { key: "w9", label: "W-9 on file" },    ] as const;    return (        <dl className="flex w-full max-w-md flex-col rounded-xl bg-card py-1 shadow-border">            {rows.map((row) => (                <div                    key={row.key}                    className="grid grid-cols-[8rem_minmax(0,1fr)] items-center gap-3 px-4 py-1"                >                    <dt className="text-13 text-muted-foreground">                        {row.label}                    </dt>                    <dd className="min-w-0 text-13">                        <EditableText                            label={row.label}                            value={values[row.key]}                            onCommit={(next) => {                                setValues((current) => ({                                    ...current,                                    [row.key]: next,                                }));                                toast.add({                                    title: `Updated ${row.label.toLowerCase()}`,                                });                            }}                            className="h-8 px-2"                            inputClassName="h-8 px-2"                        />                    </dd>                </div>            ))}        </dl>    );}

Multiline

A three-row textarea that commits on ⌘Enter. At rest the text clamps to three lines and keeps its line breaks.

Enter adds a line. ⌘Enter saves, Escape cancels.

import { EditableText } from "@oration/canon/components/editable-text";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function Multiline() {    const [notes, setNotes] = React.useState(        "Remits by ACH on Fridays. Send remittance advice to billing@orchardstreet.com and copy Wen Zhou on anything over $10,000.",    );    return (        <div className="w-full max-w-md text-sm leading-relaxed text-muted-foreground">            <EditableText                label="Payment notes"                value={notes}                placeholder="Add payment notes"                multiline                onCommit={(next) => {                    setNotes(next);                    toast.add({ title: "Saved payment notes" });                }}                className="-mx-1.5 text-pretty"            />            <p className="mt-2 text-xs text-subtle-foreground">                Enter adds a line. ⌘Enter saves, Escape cancels.            </p>        </div>    );}

In a page header

The last crumb of the app header renames the workflow in place. The wrapper carries the 13px medium type, so the editor matches it.

Workflows
import { EditableText } from "@oration/canon/components/editable-text";import { toast } from "@oration/canon/components/toast";import { ChevronRightIcon } from "lucide-react";import * as React from "react";export function InHeader() {    const [name, setName] = React.useState("Invoice approval over $25,000");    return (        <div className="flex h-12 w-full max-w-2xl items-center gap-1.5 rounded-xl bg-background px-3 text-13 shadow-border">            <span className="text-muted-foreground">Workflows</span>            <ChevronRightIcon                aria-hidden="true"                className="size-3.5 text-subtle-foreground"            />            <div className="min-w-0 font-medium text-foreground">                <EditableText                    label="Workflow name"                    value={name}                    onCommit={(next) => {                        if (!next) return;                        setName(next);                        toast.add({                            type: "success",                            title: `Renamed to ${next}`,                        });                    }}                    className="-ml-1.5 w-auto max-w-md"                />            </div>        </div>    );}

States#

Rest
Hover
Focus
Editing
Empty
import { EditableText } from "@oration/canon/components/editable-text";import { cn } from "@oration/canon/lib/utils";export function StatesRow() {    const states = [        { name: "Rest", className: "" },        { name: "Hover", className: "bg-muted" },        { name: "Focus", className: "ring-3 ring-ring/40" },    ];    return (        <div            inert            className="grid w-full grid-cols-1 gap-6 text-13 sm:grid-cols-2 lg:grid-cols-5"        >            {states.map((state) => (                <div                    key={state.name}                    className="pointer-events-none flex min-w-0 flex-col gap-2"                >                    <span className="text-xs text-muted-foreground">                        {state.name}                    </span>                    <EditableText                        label={`Legal name, ${state.name}`}                        value="Halcyon Logistics"                        onCommit={() => undefined}                        className={cn("h-8 px-2", state.className)}                    />                </div>            ))}            <div className="pointer-events-none flex min-w-0 flex-col gap-2">                <span className="text-xs text-muted-foreground">Editing</span>                <input                    aria-label="Legal name, Editing"                    tabIndex={-1}                    readOnly                    defaultValue="Halcyon Logistics"                    className="h-8 w-full min-w-0 rounded-md bg-background px-2 py-1 text-inherit ring-2 ring-ring/50 outline-none"                />            </div>            <div className="pointer-events-none flex min-w-0 flex-col gap-2">                <span className="text-xs text-muted-foreground">Empty</span>                <EditableText                    label="Trade name"                    value=""                    onCommit={() => undefined}                    className="h-8 px-2"                />            </div>        </div>    );}
States
StateTreatment
RestThe value as plain text; nothing marks it as editable until hover.
HoverA Well Gray fill over 100ms and a text cursor.
Focus visibleA 3px indigo ring at 40% on the display button.
EditingThe input replaces the button in place, with a 2px indigo ring at 50% and the text selected.
EmptyThe placeholder in Faint Slate.

Behavior#

  • Click, Enter or Space on the display button opens the editor with the whole value selected.
  • Enter commits a single-line value. With multiline, Enter adds a new line and ⌘Enter commits.
  • Escape restores the previous value and closes the editor. Blur commits.
  • The committed value is trimmed. onCommit only runs when it differs from value, and it does run with an empty string, so guard empties in your handler.
  • It holds no saved state of its own: update value from onCommit, and a new value from outside replaces the draft while not editing.
  • className styles the display button; inputClassName styles the editor. The editor's font comes from the parent element.

Do and don't#

Do. Set the heading type on the wrapper so the editor matches the text it replaces.
Don't. Put text-lg only on className. Click it: the editor drops to 14px and the header jumps.
Do. Label it with the attribute's name, such as Legal name, so it is announced as Legal name: Northwind Freight LLC. Edit.
Don't. Leave the value to name itself. A row of bare values reads as a list of buttons with no meaning.

Content#

  • The placeholder invites the first value: Add a description, Add a legal name. Use Empty only in dense attribute lists.
  • Confirm renames with the new name: Renamed to Halcyon Logistics.
  • If an empty value isn't allowed, restore the old one and say why in a toast: A supplier needs a name.

Accessibility#

  • label is required. The display button is announced as {label}: {value}. Edit, and the editor uses label as its name.
  • The display is a real <button>, so it is in the tab order and opens with Enter or Space.
  • The editor takes focus when it opens and selects its text.
  • After Enter or Escape, focus is not returned to the display button; see the gaps below.
  • Keep the hit area at least 24px tall; h-7 or h-8 in attribute lists.
Keyboard interactions
KeysAction
EnterOn the display button, opens the editor. In a single-line editor, commits.
SpaceOpens the editor from the display button.
⌘EnterCommits a multiline value.
EscRestores the previous value and closes the editor.
TabLeaves the editor, which commits.

Design tokens#

Design tokens
TokenUsed for
--mutedHover fill
--ringFocus ring at 40% and the editing ring at 50%
--backgroundEditor fill
--subtle-foregroundPlaceholder text
--radius-md8px corners

API reference#

EditableText

Click-to-edit text.

Props of EditableText
PropTypeDefaultDescription
valueRequiredstringNo defaultThe saved value.
onCommitRequired(value: string) => voidNo defaultCalled with the trimmed value when it changed.
labelRequiredstringNo defaultThe attribute's name, used for both the button and the editor.
placeholderstring"Empty"Shown when the value is empty.
multilinebooleanfalseA three-row textarea; ⌘Enter commits and the display clamps to three lines.
classNamestringNo defaultClasses for the display button.
inputClassNamestringNo defaultClasses for the editor.

Known gaps#

Where the implementation and the system disagree today. Follow the system, not the gap.

Focus isn't returned to the display button after Enter or Escape. The editor unmounts and focus falls to the page, so keyboard users lose their place.

Multiline commits only on ⌘Enter (metaKey). On Windows and Linux, Ctrl+Enter inserts a new line instead.

Empty values commit. There is no validation, error or pending state for a save that fails.

className doesn't reach the editor. Call sites that set heading type on it, such as the ticket title, change size when editing starts.

The editing ring is 2px while the suite's field focus ring is 3px, and the hover fill runs 100ms against the 150ms standard.