Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Toolbar morph

A floating toolbar folded into three tools that unfolds into any of them (text tools, a voice recorder or a time range) and folds back.

toolbar-morph/toolbar-morph.tsx
Live · interactive

The 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

  • A floating toolbar over a canvas or a note, where three or four tools each need a row of controls.
  • Tools used one at a time: the bar folds to a small pill and unfolds into the one in use.
  • Short, direct controls: formatting toggles, a voice note, a start and an end time.
  • Touch or pointer surfaces with little room, where a full toolbar would cover the work.

When not to use

  • Tools people combine at once, such as bold with a colour and a list. Use a flat toolbar that shows them together.
  • More than three or four tools, or tools with long menus. Use a menu bar or a side panel.
  • Precise times or dates: the spin buttons move by a quarter of an hour. Use a time field or a date picker.
  • A real recorder: the voice tool here only simulates one. Wire onSave to a microphone first.

You can add this component with the shadcn CLI or by hand:

Into components/toolbar-morph of any React project, with no setup:

Terminal
npx shadcn@latest add https://brunodelorence.com/r/toolbar-morph.json

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

note-composer.tsx
'use client';import { ToolbarMorph } from './toolbar-morph';export function NoteComposer() {  return (    <ToolbarMorph      defaultStart="12:00"      defaultEnd="12:30"      maxSeconds={60}      autoplay={false}      onAction={(action) => console.log(action)}      onSave={({ duration }) => console.log(duration)}      onTimeChange={({ start, end }) => console.log(start, end)}    />  );}

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 small pill with three round buttons for notes, voice and time. Press one and its grey disc stretches into the toolbar’s well while the pill grows around it and the tool’s controls slide in: formatting tools, a voice note that records with a live waveform, or a time range whose ends roll by a quarter of an hour. The cross folds it back.

  • Everything sits in a box the size of the unfolded toolbar. The pill, the three grey discs and each tool’s controls are boxes of that fixed size, opened and closed by a clip-path inset on a spring (a CSS transition with a linear() easing), so nothing is laid out or redrawn at every size it passes through.
  • The pressed tool’s disc opens into the well that holds its controls while the other two fade. The controls show through the same clip and slide in one after another.
  • A clip would cut the pill’s shadow, so the shadow is two fixed shadows, the folded pill’s and the unfolded one’s. The one that leaves goes at once; the one that comes waits for the pill to reach it.
  • The voice note is simulated and opens no microphone. A seeded model of speech gives a level every 280 ms, words in bursts with silences between, and these fill the waveform’s dots with bars. The time is measured, pause and resume pick up where it stopped, and a recording stops at maxSeconds.
  • The times are spin buttons: the arrow keys or the wheel move them by a quarter of an hour, the start takes the end along, and the digits roll up or down the way they went.
  • Every colour is a light-dark() token on the root, so the toolbar follows the page’s color-scheme, or the colorScheme prop; the notes icon’s front card still fills with its disc’s colour through --behind.
PropTypeDefaultDescription
defaultTool'notes' | 'voice' | 'time' | nullnullThe tool open at first, or null for the folded bar.
defaultList'numbered' | 'starred' | nullnullThe notes list turned on at first. Setting one turns the demo off.
defaultRecording{ phase: 'recording' | 'paused'; seconds: number }—A voice note under way at first (with defaultTool 'voice'); a recording is held still there until the reader presses something. Turns the demo off.
defaultStartstring'12:00'When the time range starts, as “HH:MM” on a 24-hour clock.
defaultEndstring'12:30'When it ends, as “HH:MM”.
maxSecondsnumber60The longest voice note; the recording pauses there.
onAction(action: NoteAction) => void—Called when a notes tool is pressed: color, font, size, emoji, image, numbered, starred or magic.
onSave(note: { duration: number; levels: number[] }) => void—Called with a saved voice note: its length in ms and its levels from 0 to 1.
onTimeChange(range: { start: string; end: string }) => void—Called with the new range when a time moves.
autoplaybooleantrueWhile on screen and untouched, a ghost pointer opens each tool in turn. Set it to false in a product.
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.
classNamestring—Extra class for the root, a stage of its own.

The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.

toolbar-morph.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import {  useEffect,  useEffectEvent,  useLayoutEffect,  useRef,  useState,  type ComponentType,  type CSSProperties,  type KeyboardEvent,} from 'react';import { GhostPointer, runDemo } from './ghost-pointer';import {  ArrowIcon,  ClockIcon,  CloseIcon,  ColorIcon,  EmojiIcon,  FontIcon,  ImageIcon,  MagicIcon,
toolbar-morph.module.css
/* Toolbar morph (toolbar-morph.tsx): a light grey stage with faint diagonal lines, the toolbar in   its middle; at night a near-black stage and a dark grey toolbar, its discs a step lighter. The   dock is the unfolded toolbar's box, 394 × 64; the folded pill, its discs and the unfolded well   are laid out inside it, and the dock's data-open attribute moves them on a spring (--spring,   --spring-ms). */.root {  /* Its colours, light and dark: each light-dark() pair resolves by the color-scheme the root     inherits from the page, or the one the colorScheme prop sets (below). */  /* The stage and its lines; the pill; the discs and the well; the voice tools' round buttons. */  --surface: #f3f3f3;  --hatch: rgb(0 0 0 / 0.032);  --surface-2: #fff;  --well: #f8f8f8;  --button: #ededed;  /* Times and pressed icons; icons; the elapsed time; the length; the clock and the arrow. */  --ink: #111;  --icon: #5c5c5f;  --ink-muted: #5a5a5e;  --ink-faint: #a3a3a3;  --clock: #a8a8a8;  --arrow: #a6a6a6;  /* The stage's edge; the rules between the notes groups; the waveform's empty slots, then     while recording. */
icons.tsx
/* * The toolbar's icons: outlined, drawn on a 24 × 24 grid with round caps, in the current colour, * and shown at 18 px. They are decorative; the buttons that hold them carry the labels. */type IconProps = { className?: string };const outline = {  viewBox: '0 0 24 24',  fill: 'none',  stroke: 'currentColor',  strokeWidth: 1.7,  strokeLinecap: 'round' as const,  strokeLinejoin: 'round' as const,  'aria-hidden': true,};/* The three tools of the folded bar. *//** Notes: two cards, fanned. The front one hides the back one with the button's own colour. */export function NotesIcon({ className }: IconProps) {  return (    <svg className={className} {...outline}>      <rect x="3.6" y="5.4" width="10.8" height="13.6" rx="2.6" transform="rotate(-14 9 12.2)" />
recorder.ts
/* * A voice note, simulated: no microphone is opened. The levels come from a small model of speech, * so that the waveform looks like a voice: words come in bursts of a few syllables that rise and * fall, with short silences between them. The sequence is seeded, so each recording starts the * same way. *//** A level from 0 (silence) to 1 (loudest), every SAMPLE_MS while recording. */export const SAMPLE_MS = 280;/** A small seeded random number generator (mulberry32). */function random(seed: number) {  let state = seed >>> 0;  return () => {    state = (state + 0x6d2b79f5) >>> 0;    let t = state;    t = Math.imul(t ^ (t >>> 15), t | 1);    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;  };}/** Returns the next level of a voice each time it is called. */export function voice(seed = 7) {
ghost-pointer.tsx
'use client';import type { Ref } from 'react';import styles from './toolbar-morph.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 is an arrow, and a hand over buttons. 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';/** A point in the stage's own pixels. */type Point = { x: number; y: number };export type Demo = {  /** Resolves after `ms`. */  wait: (ms: number) => Promise<void>;  /**   * Glides the pointer onto `target`: an element (its middle, or a point given as shares of its   * size) or a point on the stage.
spring.ts
/* * Springs as CSS easings. A spring pulls a value to its target and lets it settle: fast at first, * then slowing down, overshooting a little and coming back if it is bouncy. CSS `linear()` draws * any curve through a list of points, so the spring's motion is sampled once, and the same curve * serves CSS transitions and the Web Animations API. */export type Spring = {  /** The curve, as a CSS `linear()` easing. */  easing: string;  /** How long it takes to settle, in ms: the duration to play the curve over. */  duration: number;};/** Points sampled along the curve: plenty for a smooth motion. */const SAMPLES = 40;/** * A spring that covers most of the distance in about `response` ms. With a `bounce` of 0 it * settles without overshooting; at 0.3 it overshoots by about 5% and comes back. */export function spring(bounce: number, response: number): Spring {  // The damping ratio (1: no overshoot) and the natural frequency, in radians per second.  const damping = 1 - bounce;
use-autoplay.ts
'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;
  • It is a labelled toolbar of labelled buttons. The arrow keys move along it and Home and End go to its ends. Unfolding a tool from the keyboard focuses its first control. Escape or the cross folds it and gives the focus back to that tool’s button.
  • The numbered and starred lists are toggles with aria-pressed. The recorder’s buttons rename themselves with the state (“Start recording”, “Pause recording”, “Resume recording”, “Save the voice note”), and the elapsed time is a timer.
  • The times are spin buttons whose value is spoken as a time. The arrow keys move them by a quarter of an hour, Page Up and Page Down by an hour, and the wheel works too. Every change is announced (“Ends at 13:00, 1 hour.”, “Recording paused at 0:05.”).
  • Under reduced motion every state swaps at once and the demo does not play.