Format toolbar
AdvancedNewA floating toolbar over selected text: block types, bold, italic, underline, links, colours, bullets and an orb that suggests rewrites.
format-toolbar/format-toolbar.tsxThe live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.
When to use
- Formatting prose in place, where the words are the page and a fixed toolbar would crowd it.
- Notes, drafts and comments where a selection is the natural start: select, then act on it.
- A short set of inline marks (bold, italic, underline, a link, a colour) plus the block’s type.
- Offering rewrites of the selected words next to the formats, from the same place.
When not to use
- Touch-first editing: the phone’s own selection menu sits where this toolbar would. Use a toolbar docked above the keyboard.
- Documents with tables, images or many block types. Use a full editor with a fixed toolbar and a
/menu. - Plain inputs such as a title or a search field: formatting there only gets in the way.
- Actions that don’t depend on a selection (save, share, undo). Put them in the page’s own toolbar.
You can add this component with the shadcn CLI or by hand:
Into components/format-toolbar of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/format-toolbar.json- Copy the component files
Copy these files into one folder of your project, keeping their relative imports together, or download them as a ZIP:
- Import it
As shown under Usage, below.
Either way it needs React 19.2 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: it follows your page’s color-scheme, light unless the page sets one, or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.
Usage
import { FormatToolbar } from './format-toolbar';export function NoteEditor() { return ( <FormatToolbar autoplay={false} defaultText="Select any words to format them." onChange={({ runs, block }) => console.log(block, runs)} /> );}Each state below is a live instance, locked so you can study it.
The pieces it is built from, each with its measurements read live.
A block of text edited in place. Select some words and a toolbar rises over them while the rest of the text steps back to grey. It switches the block between Heading, Subheading, Paragraph and Quote, formats the words, picks their colour and, from an iridescent orb, offers a few rewrites.
- The text is a contentEditable block whose content React leaves alone. After each edit, its runs of text and marks are read back from the page, and written afresh if the browser left anything of its own behind.
- Formats are applied to the runs, which are written out again. The selection is put back by its character offsets, so it survives every change.
- The toolbar rises above the selection’s first line, starting a little before it, and glides when the selection moves. On a narrow stage it scales down to fit.
- The selected words keep their colour while the rest turns grey and fades towards the foot. Each selected line gets a rounded fill as tall as the line, and the demo’s selection is drawn with the Custom Highlight API rather than taking the page’s.
- The orb’s colours turn while it thinks, then it offers rewrites made on the spot: written-out alternatives for the demo’s words, and for any other words three simple passes (tighter, sentence case, title case).
- Every colour is a light-dark() token on the root, so it follows the page’s color-scheme, or the colorScheme prop. Each text colour has a night value, written on the run as --color and --color-dark, so coloured text stays readable on the dark editor; the orb is art and keeps its colours.
| Prop | Type | Default | Description |
|---|---|---|---|
defaultText | string | TEXT | The text to start from, without formatting (by default, a paragraph about a made-up writing app). |
defaultBlock | 'heading' | 'subheading' | 'paragraph' | 'quote' | 'paragraph' | The block’s type to start from. |
defaultState | FormatToolbarState | — | Opens in a given moment, with the demo off: the selected span, formats already on the text, the bullet, the open menu (rewrite, block, color or link) and its highlighted row, a tool’s tip, and the orb still thinking. |
rewrites | (text: string) => Rewrite[] | rewritesOf | Up to three rewrites of the selected text for the orb, each a label and a text (rewrites.ts). |
onChange | (value: { runs: Run[]; block: Block; listed: boolean }) => void | — | Called after each change with the runs, the block’s type and whether it has its bullet. |
autoplay | boolean | true | While on screen and untouched, a ghost pointer selects words, shows Bold’s tip, applies it and opens the block types. |
colorScheme | 'light' | 'dark' | 'system' | — | Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one. |
className | string | — | Extra class for the stage. |
The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useEffect, useEffectEvent, useLayoutEffect, useRef, useState, type KeyboardEvent,} from 'react';import { GhostPointer, runDemo, type Point } from './ghost-pointer';import { rewritesOf, type Rewrite } from './rewrites';import { COLORS, format, fromDOM, marksOf, rangeOf, replace, select, selectedSpan, serialize,/* Format toolbar (format-toolbar.tsx, toolbar.tsx, orb.tsx): a stage with a block of text, which fades towards its foot, and the toolbar that floats over its selection. Memory. The stage is one layout boundary of a fixed height. The text and the toolbar each paint on a layer of their own: typing and formatting repaint the text alone, and the toolbar moves and fades with transforms and opacity. A layer that changes size gets new buffers, so each keeps one size: the text's is clipped to its box, and the toolbar's tips and menus are small layers of their own. The text fades into the stage's colour painted over it, in its own layer: a mask would be a second layer as large, painted again with every change. Set --stage with the stage's background. Colours are the tokens below, light, then light-dark(light, dark) pairs: the component takes the color-scheme it inherits from the page (light unless the page says otherwise), or the one its colorScheme prop sets (data-color-scheme). The pairs sit in @supports, which a build that rewrites light-dark() for older browsers leaves as written; without light-dark(), the light colours stay. The text colours a reader picks come from rich-text.ts, with a value for each scheme; the orb keeps its colours. */.root { /* The stage, which the text fades into, and its edge. */ --stage: #e1e1e1; --stage-edge: rgb(0 0 0 / 0.06); /* The words; the rest of the text while the toolbar shows; the toolbar's icons, and the chevron; the rewrites' labels and their notes. */'use client';import { useState, type CSSProperties, type KeyboardEvent, type ReactNode, type Ref } from 'react';import { CheckIcon, ChevronIcon, ItalicIcon, LinkIcon, ListIcon, UnderlineIcon } from './icons';import { Orb } from './orb';import type { Rewrite } from './rewrites';import { COLORS, type Marks, type Swatch } from './rich-text';import styles from './format-toolbar.module.css';/* * The floating toolbar: the rewrite orb, the block's type, bold, italic, underline, a link, the * text colour and a bulleted list. A hovered or focused tool names itself, with its shortcut, in * a light tip above it. The orb, the block's type and the colour open small menus under their * tool; the link opens an address field. A press on the toolbar never takes the focus from the * text, so the selection stays; from the keyboard it is a toolbar (Tab from the text reaches it, * the arrow keys move along it, Escape goes back to the text). */export type Block = 'heading' | 'subheading' | 'paragraph' | 'quote';export type Tool = 'rewrite' | 'block' | 'bold' | 'italic' | 'underline' | 'link' | 'color' | 'list';export type Menu = 'rewrite' | 'block' | 'color' | 'link';export type Toggle = 'bold' | 'italic' | 'underline';/* * The text's model. A block is a list of runs: pieces of text that share the same marks (bold, * italic, underline, a colour, a link). The editor writes the runs as HTML, reads them back from * the DOM after the browser has typed into it, and formats a selection by cutting the runs at its * edges and changing the marks of the runs in between. Positions are character offsets into the * block, so a selection survives a fresh render. */export type Marks = { bold?: boolean; italic?: boolean; underline?: boolean; /** The id of one of COLORS. */ color?: string; /** A link's address. */ href?: string;};export type Run = { text: string; marks: Marks };/** A stretch of the block, in characters: from `start` up to, not including, `end`. */export type Span = { start: number; end: number };/**/* * The orb's rewrite suggestions, made on the spot with no service behind them: written-out * alternatives for the demo's own words, and for any other selection three simple passes (without * filler words, in sentence case, in title case). Pass your own through the `rewrites` prop. */export type Rewrite = { label: string; text: string };/** Alternatives written out for the demo's first words. */const WRITTEN: Record<string, Rewrite[]> = { 'Margins is a calm, focused writing space': [ { label: 'Shorter', text: 'Margins is a quiet place to write' }, { label: 'Warmer', text: 'Margins is a cosy, focused home for your writing' }, { label: 'Plainer', text: 'Margins is a simple app for writing' }, ],};const FILLER = /\b(very|really|just|quite|simply|actually|basically|truly|rather)\s+/gi;const sentenceCase = (text: string) => { const lower = text.toLowerCase(); return lower.charAt(0).toUpperCase() + lower.slice(1);};/* * The rewrite button's orb: a soap bubble of pink, violet and sky blue with a lilac rim, a white * halo inside it, a gloss at its top left and two four-pointed sparkles. Its colours sit on a * layer that turns while it shimmers (the stylesheet spins it with a transform); the rim, the * gloss and the sparkles stay. */import styles from './format-toolbar.module.css';export function Orb() { return ( <span className={styles.orb} aria-hidden="true"> <span className={styles.orbColors} /> <span className={styles.orbGloss} /> <svg className={styles.sparkles} viewBox="0 0 24 24"> <path d="M13.5 3.9c.55 3.5 2 5.05 5.6 5.6-3.6.55-5.05 2.1-5.6 5.6-.55-3.5-2-5.05-5.6-5.6 3.6-.55 5.05-2.1 5.6-5.6z" /> <path d="M7 11.4c.47 3 1.7 4.33 4.8 4.8-3.08.47-4.33 1.8-4.8 4.8-.47-3-1.7-4.33-4.8-4.8 3.08-.47 4.33-1.8 4.8-4.8z" /> </svg> </span> );}/* * The toolbar's icons: outlined, drawn on a 20 × 20 grid with round caps, in the current colour. * They are decorative; the buttons that hold them carry the labels. */type IconProps = { className?: string };const outline = { viewBox: '0 0 20 20', fill: 'none', stroke: 'currentColor', strokeWidth: 1.5, strokeLinecap: 'round' as const, strokeLinejoin: 'round' as const, 'aria-hidden': true,};/** A slanted capital with serifs: italic. */export function ItalicIcon({ className }: IconProps) { return ( <svg className={className} {...outline}> <path d="M8.6 3.6h6.2M5.2 16.4h6.2M11.7 3.6 8.3 16.4" /> </svg> );'use client';import type { Ref } from 'react';import styles from './ghost-pointer.module.css';/* * The demo's ghost pointer, and the timeline that moves it. A demo is a script of awaited steps: * wait, glide the pointer onto a control, press it and call the component's own handler, as a * click would. The pointer takes the shape a real one would: an arrow, a hand over buttons, an * I-beam over text. Its shapes are drawn once, each in a small layer of its own, and swapped by * scaling those layers to nothing and back: a change of opacity or of a rule matched through the * pointer would reach the drawings, and Safari would lay out the whole page again. Stopping the * demo (the reader takes over, or it scrolls away) ends the step in progress at once: its promise * rejects, which ends the script right there, and the pointer fades. */export type Shape = 'arrow' | 'hand' | 'text';/** A point in the stage's own pixels. */export type Point = { x: number; y: number };export type Demo = { /** Resolves after `ms`. */ wait: (ms: number) => Promise<void>;/* The demo's pointer (ghost-pointer.tsx): an arrow, a hand over buttons, an I-beam over text. Its tip sits at the point it is moved to. Each shape sits in a small layer of its own. Its colours are the stage's tokens (format-toolbar.module.css). */.ghost { position: absolute; z-index: 40; top: 0; left: 0; width: 0; height: 0; pointer-events: none; opacity: 0; transition: opacity 0.3s ease;}.shape { position: absolute; width: 22px; height: 22px; /* The script shows one shape at a time by its scale. */ transform: scale(0); will-change: transform;}.shape svg {'use client';import { useCallback, useEffect, useRef, useState } from 'react';/* * When a demo may play on its own: while part of it is on screen and the tab is visible, never * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it * takes back over `idle` ms after their last input. */export function useAutoplay<T extends HTMLElement>(idle = 6000) { const ref = useRef<T>(null); const [visible, setVisible] = useState(false); const [reduced, setReduced] = useState(true); const [taken, setTaken] = useState(false); const timer = useRef(0); useEffect(() => { const element = ref.current; if (!element) return; let onScreen = false; const update = () => setVisible(onScreen && !document.hidden); const observer = new IntersectionObserver( ([entry]) => { onScreen = entry.isIntersecting;- The text is a multiline textbox. ⌘B, ⌘I and ⌘U toggle formats and ⌘K opens the link field (Ctrl elsewhere), and the browser’s own formatting commands are routed through the runs.
- The toolbar is a role="toolbar": Tab from the text reaches it, the arrow keys, Home and End move along it, and Escape returns to the text with the selection intact. A press on it never takes focus from the text.
- The block types are a menu of menuitemradio items, the colours a group of pressed buttons, and the rewrites a menu marked busy while the orb thinks. Each tool states its pressed state and shortcut, and its tip shows on hover or keyboard focus.
- Format changes, block changes and rewrites are announced in a polite live region. Under reduced motion the orb doesn’t think first and the demo never plays.