yugodesign

Navigation·06.8

Dynamic Toolbar

A toolbar that becomes the field it opened.

Field notes

12 notes, 2 pinned

dynamic-toolbar

Install

One dependency. The component is copied into your project, so the file is yours after that.

terminal
bun add motion

Or let the shadcn CLI do the copying: same file, landing in components/yugo.

terminal
bunx shadcn@latest add https://yugo.click/r/dynamic-toolbar.json

Usage

stats.tsx
"use client";

import { useState } from "react";
import { DynamicToolbar, ToolbarAction } from "@/components/yugo/dynamic-toolbar";

export function NotesBar() {
  const [mode, setMode] = useState("actions");
  const [query, setQuery] = useState("");

  return (
    <DynamicToolbar
      label="Notes toolbar"
      mode={mode}
      onModeChange={setMode}
      modes={[
        {
          id: "actions",
          content: (
            <ToolbarAction label="Search notes" onClick={() => setMode("search")}>
              <SearchIcon />
            </ToolbarAction>
          ),
        },
        {
          id: "search",
          content: (
            <input
              value={query}
              onChange={(event) => setQuery(event.currentTarget.value)}
              aria-label="Search notes"
              className="h-10 w-[212px] rounded-[10px]"
            />
          ),
        },
      ]}
    />
  );
}

Source

components/yugo/dynamic-toolbar.tsx
"use client";

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
  motion,
  useIsomorphicLayoutEffect,
  useReducedMotion,
} from "motion/react";

const SHELL = {
  type: "spring",
  stiffness: 380,
  damping: 38,
  mass: 0.7,
} as const;

const CROSSFADE = {
  type: "spring",
  stiffness: 260,
  damping: 34,
  mass: 0.8,
} as const;

const INSTANT = { duration: 0 } as const;

const FOCUSABLE =
  'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';

type Box = { width: number; height: number };

export type UseDynamicToolbarOptions = {
  modes: readonly string[];
  mode?: string;
  defaultMode?: string;
  onModeChange?: (mode: string) => void;
};

export type UseDynamicToolbarResult = {
  rootRef: React.RefObject<HTMLDivElement | null>;
  mode: string;
  /** the active mode's own measured box, or null before the first read */
  box: Box | null;
  ready: boolean;
  setMode: (mode: string) => void;
  dismiss: () => void;
  measure: (id: string) => (node: HTMLDivElement | null) => void;
};

/**
 * The shell travels to a measured number, never to a magic one. Each mode is
 * laid out at its natural width and watched by one ResizeObserver, so adding
 * a button or lengthening a placeholder changes the shell without anybody
 * editing a pixel value.
 */
export function useDynamicToolbar({
  modes,
  mode: controlled,
  defaultMode,
  onModeChange,
}: UseDynamicToolbarOptions): UseDynamicToolbarResult {
  const home = defaultMode ?? modes[0] ?? "";

  const rootRef = useRef<HTMLDivElement>(null);
  const [uncontrolled, setUncontrolled] = useState(home);
  const [boxes, setBoxes] = useState<Record<string, Box>>({});
  const [turn, setTurn] = useState(0);

  const mode = controlled ?? uncontrolled;

  const panels = useRef(new Map<string, HTMLDivElement>());
  const binders = useRef(
    new Map<string, (node: HTMLDivElement | null) => void>(),
  );
  const observer = useRef<ResizeObserver | null>(null);

  const emit = useRef(onModeChange);
  emit.current = onModeChange;

  const live = useRef({ mode, home });
  live.current = { mode, home };

  const read = useCallback((id: string, node: HTMLElement) => {
    const rect = node.getBoundingClientRect();
    setBoxes((prev) => {
      const at = prev[id];
      // An epsilon, so a fractional width cannot feed its own observer. §21
      if (
        at &&
        Math.abs(at.width - rect.width) < 0.5 &&
        Math.abs(at.height - rect.height) < 0.5
      ) {
        return prev;
      }
      return { ...prev, [id]: { width: rect.width, height: rect.height } };
    });
  }, []);

  const measure = useCallback(
    (id: string) => {
      const cached = binders.current.get(id);
      if (cached) return cached;

      const bind = (node: HTMLDivElement | null) => {
        const previous = panels.current.get(id);
        if (previous && previous !== node) observer.current?.unobserve(previous);
        if (!node) {
          panels.current.delete(id);
          return;
        }
        panels.current.set(id, node);
        observer.current?.observe(node);
        read(id, node);
      };

      binders.current.set(id, bind);
      return bind;
    },
    [read],
  );

  useIsomorphicLayoutEffect(() => {
    const watcher = new ResizeObserver((entries) => {
      for (const entry of entries) {
        for (const [id, node] of panels.current) {
          if (node === entry.target) read(id, node);
        }
      }
    });

    observer.current = watcher;
    for (const [id, node] of panels.current) {
      watcher.observe(node);
      read(id, node);
    }

    return () => {
      watcher.disconnect();
      observer.current = null;
    };
  }, [read]);

  const setMode = useCallback(
    (next: string) => {
      if (live.current.mode === next) return;
      if (controlled === undefined) setUncontrolled(next);
      emit.current?.(next);
      setTurn((count) => count + 1);
    },
    [controlled],
  );

  const dismiss = useCallback(() => setMode(live.current.home), [setMode]);

  // A toolbar that becomes a field has to put the caret in the field. The
  // count, not the mode, is the dependency: mounting is not a mode change,
  // so nothing is stolen on the first paint.
  useEffect(() => {
    if (turn === 0) return;
    const node = panels.current.get(live.current.mode);
    node?.querySelector<HTMLElement>(FOCUSABLE)?.focus({ preventScroll: true });
  }, [turn]);

  useEffect(() => {
    if (mode === home) return;

    const onPointerDown = (event: PointerEvent) => {
      const target = event.target as Node | null;
      if (!target || rootRef.current?.contains(target)) return;
      dismiss();
    };

    const onKeyDown = (event: KeyboardEvent) => {
      if (event.key !== "Escape") return;
      if (!rootRef.current?.contains(document.activeElement)) return;
      event.stopPropagation();
      dismiss();
    };

    document.addEventListener("pointerdown", onPointerDown, true);
    document.addEventListener("keydown", onKeyDown, true);
    return () => {
      document.removeEventListener("pointerdown", onPointerDown, true);
      document.removeEventListener("keydown", onKeyDown, true);
    };
  }, [mode, home, dismiss]);

  const box = boxes[mode] ?? null;

  return { rootRef, mode, box, ready: box !== null, setMode, dismiss, measure };
}

export type ToolbarMode = {
  id: string;
  content: React.ReactNode;
};

export type DynamicToolbarProps = Omit<UseDynamicToolbarOptions, "modes"> & {
  modes: readonly ToolbarMode[];
  label: string;
  className?: string;
};

export function DynamicToolbar({
  modes,
  label,
  className = "",
  ...options
}: DynamicToolbarProps) {
  const reduced = useReducedMotion();
  const ids = useMemo(() => modes.map((entry) => entry.id), [modes]);

  const { rootRef, mode, box, measure } = useDynamicToolbar({
    ...options,
    modes: ids,
  });

  return (
    <div ref={rootRef} className={`inline-block ${className}`}>
      <motion.div
        role="group"
        aria-label={label}
        initial={false}
        animate={box ? { width: box.width, height: box.height } : {}}
        transition={reduced ? INSTANT : SHELL}
        style={{
          overflow: "hidden",
          width: box ? undefined : "max-content",
          height: box ? undefined : "auto",
        }}
        className="relative rounded-[14px] border border-stone-200 bg-white shadow-[0_1px_2px_rgba(28,25,23,0.06),0_4px_10px_-8px_rgba(28,25,23,0.45)] dark:border-white/[0.16] dark:bg-[#1D1D1A] dark:shadow-[0_1px_6px_rgba(0,0,0,0.45)]"
      >
        {modes.map((entry) => {
          const active = entry.id === mode;
          return (
            <motion.div
              key={entry.id}
              ref={measure(entry.id)}
              inert={!active}
              aria-hidden={!active}
              initial={false}
              animate={{ opacity: active ? 1 : 0 }}
              transition={
                reduced
                  ? INSTANT
                  : { ...CROSSFADE, delay: active ? 0.05 : 0 }
              }
              // The inactive modes stay mounted at their natural width so
              // they can be measured; only the active one is in flow, so the
              // shell has a real size before anything has been observed.
              className={`w-max p-1 ${
                active ? "relative" : "pointer-events-none absolute left-0 top-0"
              }`}
            >
              {entry.content}
            </motion.div>
          );
        })}
      </motion.div>
    </div>
  );
}

export type ToolbarActionProps = {
  children: React.ReactNode;
  label: string;
  onClick?: () => void;
  disabled?: boolean;
};

/** The key inside the shell: 14 outer − 4 padding = 10, which is also the
 *  height every field in the set is drawn at, so a mode that becomes an
 *  input sits on the same line as the keys it replaced. §4 */
export function ToolbarAction({
  children,
  label,
  onClick,
  disabled = false,
}: ToolbarActionProps) {
  return (
    <button
      type="button"
      onClick={onClick}
      disabled={disabled}
      aria-label={label}
      className="grid size-10 shrink-0 place-items-center rounded-[10px] text-stone-500 outline-none transition-colors duration-150 hover:bg-stone-100 hover:text-stone-800 focus-visible:bg-[#4568FF]/[0.06] focus-visible:shadow-[inset_0_0_0_1px_#4568FF] disabled:pointer-events-none disabled:opacity-50 dark:text-stone-400 dark:hover:bg-white/10 dark:hover:text-stone-100 dark:focus-visible:bg-[#93B0FF]/[0.1] dark:focus-visible:shadow-[inset_0_0_0_1px_#93B0FF]"
    >
      {children}
    </button>
  );
}

Props

modes
readonly ToolbarMode[]

The states the shell can be in. Each is { id, content }; the first is home, which is where Escape and an outside press send it back to.

label
string

The accessible name of the group.

mode
string

Controlled mode. Supplying it makes the parent the source of truth.

defaultMode
string

Uncontrolled starting mode, and the mode dismissal returns to. Defaults to the first entry.

onModeChange
(mode: string) => void

Fires with the next mode on every change, controlled or not.

className""
string

Appended last to the wrapper around the shell.