Base components
Text editor toolbar
A row of formatting tools for a rich text field, fixed above the field or floating over the selected text, where pressed tools ease into their on state and the floating bar rises from the selection.
Live preview
Loading preview
A Simple and an Advanced toolbar above a text field, and a field with the floating bar that shows when the demo selects the words "motion first". Pressing Bold turns it on for the selection. Replay selects the words again so the floating bar rises.
Installation
Run this in your app after the one-time setup on the Getting started page.
$ npx @ganisigit/lunia-ds add text-editor-toolbarUsage
The demo above, as code.
'use client';
import * as React from 'react';
import { cn } from '../lib/lunia-utils';
import { TooltipProvider } from '../tooltip/tooltip';
import { TextEditorToolbar } from './text-editor-toolbar';
import { ALIGN_TOOLS } from './text-editor-toolbar-variants';
function usePressed(initial: string[]) {
const [pressed, setPressed] = React.useState(initial);
const onToolPress = ({ tool, pressed: on }: { tool: string; pressed: boolean }) =>
setPressed((list) => {
const rest = list.filter(
(t) =>
t !== tool && (!on || !ALIGN_TOOLS.includes(tool as never) || !ALIGN_TOOLS.includes(t as never)),
);
return on ? [...rest, tool] : rest;
});
return { pressed, onToolPress };
}
const field = 'rounded-lg border border-primary bg-primary px-3.5 py-3 text-md text-primary shadow-xs';
export default function TextEditorToolbarDemo({ replay = 0 }: { replay?: number }) {
const simple = usePressed(['left']);
const advanced = usePressed(['left']);
const floating = usePressed(['left']);
const words = React.useRef<HTMLSpanElement>(null);
const [selected, setSelected] = React.useState(false);
const selectWords = React.useCallback(() => {
const node = words.current;
const selection = window.getSelection();
if (!node || !selection) return;
const range = document.createRange();
range.selectNodeContents(node);
selection.removeAllRanges();
selection.addRange(range);
}, []);
React.useEffect(() => {
const onChange = () => {
const selection = window.getSelection();
const node = words.current;
setSelected(
Boolean(
node &&
selection &&
!selection.isCollapsed &&
selection.rangeCount &&
node.contains(selection.anchorNode),
),
);
};
document.addEventListener('selectionchange', onChange);
return () => document.removeEventListener('selectionchange', onChange);
}, []);
React.useEffect(() => {
if (replay > 0) window.getSelection()?.removeAllRanges();
const timer = window.setTimeout(selectWords, 300);
return () => window.clearTimeout(timer);
}, [replay, selectWords]);
const anchor = React.useCallback(() => {
const selection = window.getSelection();
return selection?.rangeCount ? selection.getRangeAt(0).getBoundingClientRect() : null;
}, []);
const on = (tool: string) => floating.pressed.includes(tool);
return (
<TooltipProvider>
<div className="flex w-full max-w-2xl flex-col gap-8">
<div className="flex flex-col gap-3">
<TextEditorToolbar pressed={simple.pressed} onToolPress={simple.onToolPress} />
<div className={cn(field, 'min-h-20')}>Write a short note for the team.</div>
</div>
<div className="flex flex-col gap-3">
<TextEditorToolbar type="advanced" pressed={advanced.pressed} onToolPress={advanced.onToolPress} />
<div className={cn(field, 'min-h-20')}>Plan the release notes for version 2.4.</div>
</div>
<div className="flex flex-col gap-3 pt-14">
<p className={field}>
Lunia is a{' '}
<span
ref={words}
className={cn(
on('bold') && 'font-semibold',
on('italic') && 'italic',
on('underline') && 'underline',
)}
>
motion first
</span>{' '}
design system for Next.js and Nuxt.
</p>
<TextEditorToolbar
placement="floating"
anchor={selected ? anchor : null}
pressed={floating.pressed}
onToolPress={floating.onToolPress}
/>
</div>
</div>
</TooltipProvider>
);
}
API
Parts: root (toolbar), font and size selects (Advanced, Dropdowns), tool button: icon, on state (Current), color tool: color dot and color menu, divider between tool groups, tool tooltip (name and shortcut), floating bar (Simple dark pill or Advanced light pill).
| Prop | Type | Default | Description |
|---|---|---|---|
| type | "simple" | "advanced" | "simple" | Simple has the basic text tools; Advanced adds the font and size selects, Link, Insert image and Generate. |
| placement | "static" | "floating" | "static" | static sits above the field; floating shows over the selected text and hides when nothing is selected. |
| anchor | DOMRect | (() => DOMRect | null) | undefined | Floating only: the box of the selection to place the bar over. The editor passes it; null hides the bar. |
| dropdowns | boolean | true | Advanced only: shows the font and size selects. |
| pressed | string[] | [] | Tools that are on for the current selection, for example ["bold", "left"]. The editor owns this state. |
| disabled | string[] | [] | Tools that cannot be used right now. |
| labels | Record<string, string> | English tool names | Tool names for tooltips and aria-label, for example { bold: "Bold" }. |
| color | string | undefined | Current text color as a hex value; the color tool dot shows it. |
| font | string | the first font | Advanced: the current font value. |
| fonts | { value: string; label: string }[] | Inter, Geist Mono, Serif | Advanced: the fonts in the font select. |
| textSize | string | "16" | Advanced: the current text size value. |
| textSizes | { value: string; label: string }[] | 12px to 24px | Advanced: the sizes in the size select. |
| Event | Description |
|---|---|
| toolPress | A tool was pressed. Payload: { tool, pressed } with the tool id and its new on state. |
| colorChange | A text color was picked. Payload: the color token name or a hex value. |
| fontChange | Advanced: a font was picked. Payload: the font value. |
| sizeChange | Advanced: a text size was picked. Payload: the size value. |
| Slot | Description |
|---|---|
| default | Replaces the built-in tools with custom TextEditorTool items (each with value, icon and label). |
| end | Extra tools at the end, for example Generate. |
Motion
Every row names a token from the Motion page. The last column says what happens when the user asks for reduced motion.
| ID | Trigger | Element | Property | From → to | Timing | Reduced motion |
|---|---|---|---|---|---|---|
| TET1 | hover a tool | tool button | background-color, icon color | transparent, text-editor-icon-fg → bg-primary_hover, text-editor-icon-fg_active | duration-sm (200)ease-out | Swaps at once |
| TET2 | a tool turns on or off (Current) | tool button | background-color, icon color | transparent, text-editor-icon-fg → bg-secondary_hover, text-editor-icon-fg_active | duration-sm (200)ease-out | Swaps at once |
| TET3 | press a tool | tool button | scale | 1 → scale-press | duration-sm (200)ease-out | Stays still |
| TET4 | keyboard focus on a tool | tool button | box-shadow | none → focus ring (2px bg-primary gap, 4px focus-ring) | duration-2xs (120)ease-out | Shows at once |
| TET5 | text is selected (floating) | floating bar (origin at the bottom center, over the selection) | opacity, translate y, scale | 0, distance-rise, scale-press → 1, 0, 1 | duration-sm (200)ease-settle | Shows at once |
| TET6 | the selection is cleared (floating) | floating bar | opacity | 1 → 0 | duration-2xs (120)ease-exit | Hides at once |
| TET7 | the selection changes while the bar is open (floating) | floating bar | translate x, translate y | old selection position → new selection position | duration-sm (200)ease-out | Moves at once |
Accessibility
- Built on a toolbar primitive: role toolbar with aria-label "Text formatting"; one Tab stop, Left and Right move between tools, Home and End jump.
- On and off tools (Bold, Italic, Underline, alignment, Dot points) are toggle buttons with aria-pressed; alignment tools act as one group where only one is on.
- Every tool has an aria-label and a tooltip with its name and shortcut, for example "Bold" and "Ctrl B" ("⌘B" on macOS); the shortcut is also in aria-keyshortcuts.
- Tools are 32px, which meets the 24 x 24px target (WCAG 2.5.8).
- The floating bar does not take focus when it shows; Alt F10 (or the editor's own key) moves focus into it, and Escape returns focus to the text.
- Reduced motion: tools change state at once, nothing scales, and the floating bar shows, hides and moves at once.