Suggested edits shown in place: the words that could change are marked in the text with a different underline per kind, and each suggestion says what changes, why, and what the text becomes. Accept, Dismiss, Accept all, or edit it yourself.
Your draft
Hi Maria, I wanted to reach out because we was thinking that it is very important to basically get the refund sorted out for you as soon as possible. Our team have looked at your order and it seems like the item arrived damaged, which we are really really sorry about.
Suggestions 5
Remove: we wasUse instead: we wereThe subject and the verb do not agree.
Remove: it is very important to basically get the refund sorted out for you as soon as possibleUse instead: we will sort out your refund todayRemove: Our team have lookedUse instead: Our team has lookedRemove: it seems like the item arrived damagedUse instead: your item arrived damagedRemove: really really sorryUse instead: sorry
"use client"
import { WritingAssistant, type WritingSuggestion } from "@/components/ballmac/writing-assistant"
const text = "Hi Maria, I wanted to reach out because we was thinking that it is very important to basically get the refund sorted out for you as soon as possible. Our team have looked at your order and it seems like the item arrived damaged, which we are really really sorry about."
const suggestions: WritingSuggestion[] = [
{ id: "1", find: "we was", replace: "we were", kind: "grammar", reason: "The subject and the verb do not agree." },
{ id: "2", find: "it is very important to basically get the refund sorted out for you as soon as possible", replace: "we will sort out your refund today", kind: "clarity", reason: "Says what will happen, and when, in fewer words." },
{ id: "3", find: "Our team have looked", replace: "Our team has looked", kind: "grammar", reason: "Team is singular here." },
{ id: "4", find: "it seems like the item arrived damaged", replace: "your item arrived damaged", kind: "tone", reason: "You have seen the photos. Say it plainly instead of hedging." },
{ id: "5", find: "really really sorry", replace: "sorry", kind: "length", reason: "One clear apology lands better than two." },
]
export default function WritingAssistantDemo() {
return (
<div className="w-full max-w-4xl">
<WritingAssistant defaultText={text} suggestions={suggestions} />
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/writing-assistantInstall the dependencies.
$ pnpm add lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/i18nCopy the source into your project.
components/ballmac/writing-assistant.tsx// Ballmac UI: Writing Assistant. https://ui.ballmac.com/components/writing-assistant "use client" import * as React from "react" import { Check, Pencil, X } from "lucide-react" import { cn } from "@/lib/utils" import { useMessages } from "@/lib/ballmac/i18n" type SuggestionKind = "grammar" | "clarity" | "tone" | "length" type WritingSuggestion = { id: string /** The exact words in the text to replace. The first match is the one marked. */ find: string /** What to put there instead. */ replace: string /** Why, in a sentence. */ reason: string kind?: SuggestionKind } type WritingAssistantProps = Omit<React.ComponentProps<"section">, "title" | "onChange" | "defaultValue"> & { /** The text. Controlled. */ text?: string /** The text to start with, for uncontrolled use. */ defaultText?: string onTextChange?: (text: string) => void suggestions: WritingSuggestion[] title?: string /** Called when a suggestion is accepted, after the text has changed. */ onAccept?: (suggestion: WritingSuggestion) => void /** Called when a suggestion is dismissed. */ onDismiss?: (suggestion: WritingSuggestion) => void } const KIND: Record<SuggestionKind, { label: string; mark: string }> = { grammar: { label: "Grammar", mark: "decoration-wavy" }, clarity: { label: "Clarity", mark: "decoration-dotted" }, tone: { label: "Tone", mark: "decoration-dashed" }, length: { label: "Length", mark: "decoration-double" }, } type Segment = { text: string; suggestion?: WritingSuggestion } /** Splits the text where suggestions apply. A suggestion whose words are not in the text, or overlap an earlier one, is left out. Exported for reuse and tests. */ function segmentText(text: string, suggestions: WritingSuggestion[]): { segments: Segment[]; applied: WritingSuggestion[] } { const found = suggestions .map((s) => ({ s, at: s.find ? text.indexOf(s.find) : -1 })) .filter((x) => x.at >= 0) .sort((a, b) => a.at - b.at) const segments: Segment[] = [] const applied: WritingSuggestion[] = [] let cursor = 0 for (const { s, at } of found) { if (at < cursor) continue if (at > cursor) segments.push({ text: text.slice(cursor, at) }) segments.push({ text: s.find, suggestion: s }) applied.push(s) cursor = at + s.find.length } if (cursor < text.length) segments.push({ text: text.slice(cursor) }) return { segments, applied } } /** * Suggested edits to a piece of writing, shown in place: the words that could change are marked in the text (with a different underline for each kind, * so it never depends on colour), and each suggestion says what it would change, why, and what the text becomes. Accept makes the edit; Dismiss hides it; * Accept all does the lot. You can still edit the text yourself, and a suggestion whose words are no longer there quietly drops away. */ function WritingAssistant({ text: textProp, defaultText = "", onTextChange, suggestions, title, onAccept, onDismiss, className, ...props }: WritingAssistantProps) { const msg = useMessages() const id = React.useId() title ??= msg("writing-assistant.title", "Suggestions") const [inner, setInner] = React.useState(defaultText) const text = textProp ?? inner const [dismissed, setDismissed] = React.useState<string[]>([]) const [selected, setSelected] = React.useState<string | null>(null) const [editing, setEditing] = React.useState(false) const [announce, setAnnounce] = React.useState("") const kindText = (k: SuggestionKind) => msg(`writing-assistant.kind.${k}`, KIND[k].label) const live = suggestions.filter((s) => !dismissed.includes(s.id)) const { segments, applied } = React.useMemo(() => segmentText(text, live), [text, live]) const current = applied.find((s) => s.id === selected) ?? applied[0] function setText(next: string) { if (textProp === undefined) setInner(next) onTextChange?.(next) } function accept(s: WritingSuggestion) { const at = text.indexOf(s.find) if (at < 0) return setText(text.slice(0, at) + s.replace + text.slice(at + s.find.length)) setDismissed((d) => [...d, s.id]) setSelected(null) setAnnounce(msg("writing-assistant.accepted", "Accepted: {text}", { text: s.replace })) onAccept?.(s) } function dismiss(s: WritingSuggestion) { setDismissed((d) => [...d, s.id]) setSelected(null) setAnnounce(msg("writing-assistant.dismissed", "Dismissed: {text}", { text: s.find })) onDismiss?.(s) } function acceptAll() { // From the end to the start, so earlier edits do not move the later ones. let next = text const ordered = [...applied].sort((a, b) => next.indexOf(b.find) - next.indexOf(a.find)) for (const s of ordered) { const at = next.indexOf(s.find) if (at >= 0) next = next.slice(0, at) + s.replace + next.slice(at + s.find.length) } setText(next) setDismissed((d) => [...d, ...ordered.map((s) => s.id)]) setAnnounce(msg("writing-assistant.acceptedAll", "Accepted {count} suggestions", { count: ordered.length })) ordered.forEach((s) => onAccept?.(s)) } return ( <section data-slot="writing-assistant" aria-labelledby={`${id}-t`} className={cn("bg-card text-card-foreground grid gap-0 overflow-hidden rounded-xl border md:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)]", className)} {...props}> <div className="grid min-w-0 grid-cols-[minmax(0,1fr)] content-start gap-3 p-4 sm:p-5"> <div className="flex items-center justify-between gap-3"> <h3 id={`${id}-t`} className="text-sm font-semibold">{msg("writing-assistant.draft", "Your draft")}</h3> <button type="button" aria-pressed={editing} onClick={() => setEditing((e) => !e)} className="inline-flex h-7 items-center gap-1.5 rounded-md border bg-background px-2.5 text-xs font-medium outline-none transition-colors hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50 motion-reduce:transition-none"> {editing ? <Check aria-hidden="true" className="size-3.5" /> : <Pencil aria-hidden="true" className="size-3.5" />} {editing ? msg("writing-assistant.done", "Done editing") : msg("writing-assistant.edit", "Edit the text")} </button> </div> {editing ? ( <textarea aria-label={msg("writing-assistant.textLabel", "Your draft")} value={text} onChange={(e) => setText(e.target.value)} rows={9} className="min-h-48 w-full resize-y rounded-lg border bg-background px-3.5 py-3 text-[15px] leading-7 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" /> ) : ( <p className="rounded-lg border bg-background/60 px-3.5 py-3 text-[15px] leading-7 whitespace-pre-wrap text-pretty"> {segments.map((seg, i) => seg.suggestion ? ( <span key={i} role="button" tabIndex={0} aria-label={msg("writing-assistant.mark", "Suggestion, {kind}: {text}", { kind: kindText(seg.suggestion.kind ?? "clarity").toLowerCase(), text: seg.text })} aria-pressed={current?.id === seg.suggestion.id} onClick={() => setSelected(seg.suggestion!.id)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setSelected(seg.suggestion!.id) } }} className={cn("cursor-pointer rounded-sm bg-foreground/[0.06] px-0.5 underline underline-offset-4 outline-none transition-colors [box-decoration-break:clone] hover:bg-foreground/10 focus-visible:ring-[3px] focus-visible:ring-ring/50 motion-reduce:transition-none", KIND[seg.suggestion.kind ?? "clarity"].mark, current?.id === seg.suggestion.id && "bg-foreground/15")} >{seg.text}</span> ) : ( <React.Fragment key={i}>{seg.text}</React.Fragment> ) )} </p> )} </div> <div className="grid min-w-0 grid-cols-[minmax(0,1fr)] content-start gap-3 border-t p-4 sm:p-5 md:border-s md:border-t-0"> <div className="flex items-center justify-between gap-3"> <p className="text-sm font-semibold">{title} <span className="ms-1 rounded-full bg-muted px-2 py-px text-xs font-medium tabular-nums">{applied.length}</span></p> {applied.length > 1 && !editing && <button type="button" onClick={acceptAll} className="inline-flex h-7 items-center rounded-md bg-foreground px-2.5 text-xs font-medium text-background outline-none transition-colors hover:bg-foreground/90 focus-visible:ring-[3px] focus-visible:ring-ring/50 motion-reduce:transition-none">{msg("writing-assistant.acceptAll", "Accept all")}</button>} </div> {editing ? ( <p className="text-sm text-muted-foreground">{msg("writing-assistant.whileEditing", "Suggestions come back when you finish editing.")}</p> ) : applied.length === 0 ? ( <p className="rounded-lg border border-dashed px-4 py-8 text-center text-sm text-muted-foreground">{msg("writing-assistant.none", "No suggestions. This reads well.")}</p> ) : ( <ul className="grid grid-cols-[minmax(0,1fr)] gap-2.5"> {applied.map((s) => { const on = current?.id === s.id return ( <li key={s.id} className={cn("grid grid-cols-[minmax(0,1fr)] gap-2 rounded-lg border p-3 text-sm transition-colors motion-reduce:transition-none", on && "border-foreground bg-accent/40")}> <button type="button" onClick={() => setSelected(s.id)} aria-label={msg("writing-assistant.showSuggestion", "Show: {text}", { text: s.find })} className="flex items-center gap-2 text-start outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"> <span className="rounded-full border px-2 py-px text-[11px] font-medium text-muted-foreground">{kindText(s.kind ?? "clarity")}</span> <span className="min-w-0 flex-1 truncate text-xs text-muted-foreground">{s.reason}</span> </button> <p className="leading-relaxed"> <del className="text-muted-foreground"><span className="sr-only">{msg("writing-assistant.remove", "Remove:")} </span>{s.find}</del> {" "} <ins className="font-medium no-underline"><span className="sr-only">{msg("writing-assistant.insert", "Use instead:")} </span>{s.replace || msg("writing-assistant.nothing", "(nothing)")}</ins> </p> {on && <p className="text-[13px] leading-snug text-muted-foreground">{s.reason}</p>} <div className="flex gap-2"> <button type="button" onClick={() => accept(s)} className="inline-flex h-7 items-center gap-1 rounded-md bg-primary px-2.5 text-xs font-medium text-primary-foreground outline-none transition-colors hover:bg-primary/90 focus-visible:ring-[3px] focus-visible:ring-ring/50 motion-reduce:transition-none"><Check aria-hidden="true" className="size-3.5" />{msg("writing-assistant.accept", "Accept")}<span className="sr-only">: {s.replace}</span></button> <button type="button" onClick={() => dismiss(s)} className="inline-flex h-7 items-center gap-1 rounded-md border bg-background px-2.5 text-xs font-medium outline-none transition-colors hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50 motion-reduce:transition-none"><X aria-hidden="true" className="size-3.5" />{msg("writing-assistant.dismiss", "Dismiss")}<span className="sr-only">: {s.find}</span></button> </div> </li> ) })} </ul> )} </div> <p aria-live="polite" className="sr-only">{announce}</p> </section> ) } export { WritingAssistant, segmentText, type SuggestionKind, type WritingAssistantProps, type WritingSuggestion }Update the import paths to match your project setup.
Usage
import { WritingAssistant, segmentText } from "@/components/ballmac/writing-assistant"The full example is in the Code tab above.
Examples
Nothing to suggest
Your draft
Hi Maria, your refund of $340 is on its way. It usually takes three to five working days to show on your card.
Suggestions 0
No suggestions. This reads well.
"use client"
import { WritingAssistant } from "@/components/ballmac/writing-assistant"
export default function WritingAssistantClean() {
return (
<div className="w-full max-w-4xl">
<WritingAssistant defaultText="Hi Maria, your refund of $340 is on its way. It usually takes three to five working days to show on your card." suggestions={[]} />
</div>
)
}API reference
| Prop | Type | Default |
|---|---|---|
textThe text. Controlled. | string | — |
defaultTextThe text to start with, for uncontrolled use. | string | "" |
onTextChange | (text: string) => void | — |
suggestions* | WritingSuggestion[] | — |
title | string | — |
onAcceptCalled when a suggestion is accepted, after the text has changed. | (suggestion: WritingSuggestion) => void | — |
onDismissCalled when a suggestion is dismissed. | (suggestion: WritingSuggestion) => void | — |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Marks | Each marked phrase is a button named 'Suggestion, <kind>: <words>' with pressed state; the kind is also shown by the underline style (wavy, dotted, dashed, double), never colour alone |
| Changes | Each card reads as words: 'Remove: …' and 'Use instead: …'; Accept and Dismiss name the words they affect |
| Announcements | Accepting, dismissing and Accept all are announced in a polite live region |
| Editing | A labelled textarea; suggestions pause while editing and say so |
Use with AI
suggestions is [{ id, find, replace, reason, kind?: grammar | clarity | tone | length }] (required); the first occurrence of find in the text is the one marked. text / defaultText / onTextChange, title, onAccept(suggestion), onDismiss(suggestion). Accept replaces the words and drops the suggestion; Accept all applies them all without the edits disturbing each other; one whose words are no longer in the text, or that overlaps an earlier one, is left out. Exports segmentText. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Writing Assistant (@ballmac/writing-assistant) to this project with the shadcn MCP, then use it where it fits.
Use it for
- An AI proofreading or rewriting pass over a short piece of text
- Any place where a model proposes small edits a person should approve one by one
Not for
- A full rich-text editor; use rich-text-editor
- Comparing two prompts word by word; use prompt-diff
Registry JSON: https://ui.ballmac.com/r/writing-assistant.json
Credits
Free to use in personal and commercial projects.
- npm
- lucide-react
- Registry
- @ballmac/i18nshadcn/utils
Pairs well with
Prompt Diff
Two versions of a prompt compared word by word: removed words struck through and added words underlined so colour is never the only cue, a count of each, {{variables}} marked, side by side or inline, and an optional Restore button.
Message Actions
The small bar under an AI reply: copy, try again, good or bad answer (with reasons), branch, share. A real toolbar with arrow-key movement, spoken results, and choices taken back with the reason when they cannot be saved.
AI Chat
The layout for a chat UI: a message log that sticks to the bottom while replies stream unless the reader scrolls up, a jump-to-latest button, an empty state with suggestions and a footer.
Review Center
A desk for reviewing what agents made: items with the agent's confidence, a summary and the thing itself, Approve or Request changes with a comment, J, K, A and R keys, and decisions undone with the reason if saving fails.
Ballmac UI Pro
Need the whole screen, not one piece?
150 premium blocks (dashboards, settings, calendars, mail, shops, onboarding), a private shadcn registry and Figma tokens. Blocks that show data come with loading, empty and error states built in.
Not ready? Try three Pro blocks free
Leave your email and the blocks appear right here, ready to copy. Yours to use in any project.