Lunia DS

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.

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-toolbar

Usage

The demo above, as code.

demo.tsx
'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).

PropTypeDefaultDescription
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.
anchorDOMRect | (() => DOMRect | null)undefinedFloating only: the box of the selection to place the bar over. The editor passes it; null hides the bar.
dropdownsbooleantrueAdvanced only: shows the font and size selects.
pressedstring[][]Tools that are on for the current selection, for example ["bold", "left"]. The editor owns this state.
disabledstring[][]Tools that cannot be used right now.
labelsRecord<string, string>English tool namesTool names for tooltips and aria-label, for example { bold: "Bold" }.
colorstringundefinedCurrent text color as a hex value; the color tool dot shows it.
fontstringthe first fontAdvanced: the current font value.
fonts{ value: string; label: string }[]Inter, Geist Mono, SerifAdvanced: the fonts in the font select.
textSizestring"16"Advanced: the current text size value.
textSizes{ value: string; label: string }[]12px to 24pxAdvanced: the sizes in the size select.
EventDescription
toolPressA tool was pressed. Payload: { tool, pressed } with the tool id and its new on state.
colorChangeA text color was picked. Payload: the color token name or a hex value.
fontChangeAdvanced: a font was picked. Payload: the font value.
sizeChangeAdvanced: a text size was picked. Payload: the size value.
SlotDescription
defaultReplaces the built-in tools with custom TextEditorTool items (each with value, icon and label).
endExtra 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.

IDTriggerElementPropertyFrom → toTimingReduced motion
TET1hover a tooltool buttonbackground-color, icon colortransparent, text-editor-icon-fg → bg-primary_hover, text-editor-icon-fg_activeduration-sm (200)ease-outSwaps at once
TET2a tool turns on or off (Current)tool buttonbackground-color, icon colortransparent, text-editor-icon-fg → bg-secondary_hover, text-editor-icon-fg_activeduration-sm (200)ease-outSwaps at once
TET3press a tooltool buttonscale1 → scale-pressduration-sm (200)ease-outStays still
TET4keyboard focus on a tooltool buttonbox-shadownone → focus ring (2px bg-primary gap, 4px focus-ring)duration-2xs (120)ease-outShows at once
TET5text is selected (floating)floating bar (origin at the bottom center, over the selection)opacity, translate y, scale0, distance-rise, scale-press → 1, 0, 1duration-sm (200)ease-settleShows at once
TET6the selection is cleared (floating)floating baropacity1 → 0duration-2xs (120)ease-exitHides at once
TET7the selection changes while the bar is open (floating)floating bartranslate x, translate yold selection position → new selection positionduration-sm (200)ease-outMoves 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.