Contextual Toolbar

A toolbar serving two jobs is either wide enough to show both at once and too big to sit over content, or split in two so you have to find the right one first.

LayoutAIMediaEditorintermediate
  • Keyboard
  • Responsive
  • Reduced motion
npx shadcn@latest add https://design-recreation.vercel.app/r/contextual-toolbar.json

The problem

A toolbar that serves two jobs — a text prompt and a row of tools, say — has two bad options. Show everything at once and it is wide enough to cover the thing you are editing. Split it into two toolbars and every action starts with finding the right one.

Keeping the mode switcher fixed and resizing the bar around whichever set is active gives one control surface, in one place, at the width that set actually needs. The switcher never moves, so the way back is always where you left it.

Use cases

  • An editor's floating toolbar with a manual mode and an AI prompt
  • A media or timeline bar where tools and text input compete for one strip
  • Any overlay control surface that must stay narrow enough to see past

Not for: more than three modes — the switcher grows and stops being the fixed landmark that makes this work. Not for modes that need to be visible simultaneously; this pattern trades that away deliberately. Not for a persistent toolbar with room for everything, where switching is pure overhead.

Installation

bash
npm install motion lucide-react

Then copy ContextualToolbar.tsx. It depends only on @/lib/cn.

Usage

tsx
import ContextualToolbar from "@/patterns/contextual-toolbar/ContextualToolbar";
import { Music2, Sparkle } from "lucide-react";

<ContextualToolbar
  label="Editor mode"
  defaultMode="edit"
  modes={[
    { id: "edit", label: "Audio tools", icon: <Music2 />, content: <Tools /> },
    {
      id: "ai",
      label: "Refine with AI",
      icon: <Sparkle />,
      content: <Prompt />,
    },
  ]}
/>;

Each mode owns its own content, so the bar does not care whether a mode is a form, a row of buttons or a single control — it only handles the switching and the resize.

API

PropTypeDefaultDescription
modesreadonly ToolbarMode[]—Two or three. Empty renders nothing.
labelstring—Names the switcher for assistive technology.
defaultModestringfirst modeUncontrolled initial mode.
modestringuncontrolledControlled mode. Pass with onModeChange.
onModeChange(id: string) => void—Fires on every switch.
classNamestring—Merged onto the root.

ToolbarMode: { id: string; label: string; icon: ReactNode; content: ReactNode }.

Keyboard

KeyAction
TabEnters the switcher, then moves into the active mode's content
← / →Switch mode, wrapping
Home / EndFirst or last mode

The APG tabs contract with automatic activation: arrowing to a mode selects it, because there is no cost to switching and nothing to confirm.

Accessibility

The mode icons were bare SVGs with onClick. No role, no name, no tab stop — so the only way to change mode was a mouse, and a screen reader was told nothing about there being modes at all. They are a real role="tablist" of role="tab" buttons now, with aria-selected and aria-controls pointing at the panel, and the panel points back with aria-labelledby.

This is genuinely tabs rather than a radio group: there is one panel and its contents are what changes. The library's other segmented control, expanding-segments, is a radiogroup precisely because it has no panel.

The submit button had no handler and no name, and the four tool icons were decoration — they looked like controls and were not. That is the sixth pattern in this library where an affordance existed visually but not functionally; in the migrated version the tools and the submit are the consumer's to wire, and the demo wires them.

The caret colour was a hardcoded slate that was near-invisible on a dark surface. It follows the text colour now.

Under prefers-reduced-motion the bar resizes instantly and the panels cross-fade without travel or blur.

Known limitation — touch targets. The mode tabs are 28px circles sitting in a dense pill. Their hit area is pushed out with a pseudo-element to 44px tall and to the midpoint of the gap horizontally, which is 36px wide — comfortably past the 24px WCAG 2.5.8 minimum, but under the 44px this library asks for. Closing that gap means spacing the switcher out until it stops being the small fixed landmark the whole pattern leans on, so the pattern is flagged touch: false rather than claiming otherwise. If you need thumb targets, grow the tabs and accept a taller bar.

Performance

The bar's resize is a layout animation, and the border radius is set inline rather than by a class so Motion can correct it during the projection — without that, the corners visibly distort as the width changes.

The spring's amplitude is a property of your content, not of the spring. At stiffness: 110, damping: 10 the damping ratio is 0.477, so the first peak overshoots by 18.2% — of whatever distance the bar actually travels. Give the two modes similar widths and there is nothing left to bounce: an early version of this demo padded the tool buttons out to 32px, which brought the tools row to within 12px of the prompt row and reduced the visible overshoot to 2.2px. Restoring the original's 16px icons puts the travel back at 76px and the overshoot at 13.8px.

So if this reads as flat, check the width delta before touching the spring. The tool buttons keep a 32×40 hit area through a pseudo-element, which buys the touch target back without spending the width.

will-change: transform was set permanently on two elements. That holds a compositor layer for the life of the page to buy a few frames of a 200ms animation, and it is exactly what the property is documented not to be for. Both are gone.

Direction is passed through AnimatePresence's custom rather than baked into each mode's variants. An exiting element renders with frozen props, so reading direction off the mode itself would always use the previous value and reversing would send both panels the same way.

Source

The whole component. In a copy-paste library the source is the delivery mechanism, not an appendix — so it is read straight off disk at build time and cannot drift from what ships.

ContextualToolbar.tsx
"use client";

import { useCallback, useId, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

import { cn } from "@/lib/cn";

/* -------------------------------------------------------------------------- */
/* Types                                                                       */
/* -------------------------------------------------------------------------- */

export interface ToolbarMode {
  id: string;
  /** Accessible name for the mode's tab. */
  label: string;
  icon: React.ReactNode;
  /** What the bar shows while this mode is selected. */
  content: React.ReactNode;
}

export interface ContextualToolbarProps extends Omit<
  React.ComponentPropsWithoutRef<"div">,
  "content"
> {
  /** Two or three. The switcher is always visible, so it has to stay small. */
  modes: readonly ToolbarMode[];
  /** Names the mode switcher, e.g. "Editor mode". */
  label: string;
  defaultMode?: string;
  mode?: string;
  onModeChange?: (id: string) => void;
}

/* -------------------------------------------------------------------------- */
/* Motion — kept verbatim from the original                                    */
/* -------------------------------------------------------------------------- */

/*
 * 110/10 is damping ratio 0.48 — the bar overshoots its new width noticeably.
 * That is the whole effect: the container resizing with a bit of give is what
 * makes it read as one object reshaping itself rather than as two different
 * toolbars being swapped in and out.
 */
const resize = { type: "spring" as const, stiffness: 110, damping: 10 };

const swap = { duration: 0.2, ease: "easeOut" as const };

/**
 * Direction is applied through `custom` rather than baked into each mode, so
 * the element on its way out animates with the *current* direction. Read off a
 * frozen prop it would always use the previous one, and reversing would send
 * both panels the same way.
 */
const panelVariants = {
  enter: (direction: number) => ({
    opacity: 0,
    x: 20 * direction,
    filter: "blur(4px)",
  }),
  center: { opacity: 1, x: 0, filter: "blur(0px)" },
  exit: (direction: number) => ({
    opacity: 0,
    x: -20 * direction,
    filter: "blur(4px)",
  }),
};

const reducedVariants = {
  enter: { opacity: 0 },
  center: { opacity: 1 },
  exit: { opacity: 0 },
};

/* -------------------------------------------------------------------------- */
/* Component                                                                   */
/* -------------------------------------------------------------------------- */

/**
 * A compact bar that reshapes around whichever set of controls is in use.
 *
 * A toolbar serving two jobs — a text prompt and a row of tools, say — either
 * shows everything at once and is too wide to sit over content, or splits into
 * two toolbars and makes you find the right one. Keeping the switcher fixed and
 * resizing the bar around the active set keeps one control surface in one
 * place, at the width that set actually needs.
 */
export function ContextualToolbar({
  modes,
  label,
  defaultMode,
  mode: controlledMode,
  onModeChange,
  className,
  ...rest
}: ContextualToolbarProps) {
  const reduce = useReducedMotion();
  const uid = useId();

  const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);
  const [uncontrolled, setUncontrolled] = useState(defaultMode ?? modes[0]?.id);
  const selected = controlledMode ?? uncontrolled;
  const [direction, setDirection] = useState(1);

  const selectedIndex = Math.max(
    modes.findIndex((entry) => entry.id === selected),
    0,
  );

  const select = useCallback(
    (id: string) => {
      const next = modes.findIndex((entry) => entry.id === id);
      setDirection(next >= selectedIndex ? 1 : -1);
      if (controlledMode === undefined) setUncontrolled(id);
      onModeChange?.(id);
    },
    [controlledMode, modes, onModeChange, selectedIndex],
  );

  /** APG tabs: one tab stop, arrows move and activate, Home/End jump. */
  const handleKeyDown = useCallback(
    (event: React.KeyboardEvent<HTMLButtonElement>) => {
      const keys = ["ArrowRight", "ArrowLeft", "Home", "End"];
      if (!keys.includes(event.key)) return;
      event.preventDefault();

      const last = modes.length - 1;
      const next =
        event.key === "ArrowRight"
          ? (selectedIndex + 1) % modes.length
          : event.key === "ArrowLeft"
            ? (selectedIndex - 1 + modes.length) % modes.length
            : event.key === "Home"
              ? 0
              : last;

      select(modes[next].id);
      tabRefs.current[next]?.focus();
    },
    [modes, select, selectedIndex],
  );

  if (modes.length === 0) return null;

  const active = modes[selectedIndex];
  const panelId = `${uid}-panel-${active.id}`;

  return (
    <div
      className={cn("flex w-full min-w-0 justify-center", className)}
      {...rest}
    >
      <motion.div
        layout
        transition={reduce ? { duration: 0.01 } : resize}
        className={cn(
          "flex w-fit max-w-full items-center gap-1 overflow-hidden",
          "bg-surface-subtle px-2 py-1.5 shadow-md",
        )}
        // Inline, not a class: Motion needs to read the radius to correct it
        // during the layout projection, or the corners distort as it resizes.
        style={{ borderRadius: 24 }}
      >
        <motion.div
          layout="position"
          role="tablist"
          aria-label={label}
          aria-orientation="horizontal"
          className={cn(
            "relative z-10 flex shrink-0 items-center gap-2 rounded-full",
            "bg-surface px-2 py-1 shadow-sm",
          )}
        >
          {modes.map((entry, index) => {
            const isSelected = entry.id === selected;
            return (
              <button
                key={entry.id}
                ref={(node) => {
                  tabRefs.current[index] = node;
                }}
                type="button"
                role="tab"
                id={`${uid}-tab-${entry.id}`}
                aria-selected={isSelected}
                aria-controls={
                  // Only the selected panel is mounted, so pointing every tab at
                  // its own panel leaves the unselected ones dangling.
                  isSelected ? `${uid}-panel-${entry.id}` : undefined
                }
                aria-label={entry.label}
                // Roving tabindex: the switcher is a single tab stop.
                tabIndex={isSelected ? 0 : -1}
                onClick={() => select(entry.id)}
                onKeyDown={handleKeyDown}
                className={cn(
                  "focus-ring relative grid size-7 cursor-pointer place-items-center",
                  "rounded-full transition-colors duration-300 [&>svg]:size-4",
                  // The visible control is 28px, which is the design. The hit
                  // area is pushed out to 44px tall and to the midpoint of the
                  // gap horizontally, so a thumb has more to aim at than the
                  // circle without the circles overlapping each other.
                  "before:absolute before:-inset-x-1 before:-inset-y-2 before:content-['']",
                  isSelected
                    ? "bg-surface-hover text-content"
                    : "text-content-muted hover:text-content",
                )}
              >
                <span aria-hidden="true">{entry.icon}</span>
              </button>
            );
          })}
        </motion.div>

        <AnimatePresence mode="popLayout" initial={false} custom={direction}>
          <motion.div
            key={active.id}
            layout
            role="tabpanel"
            id={panelId}
            aria-labelledby={`${uid}-tab-${active.id}`}
            custom={direction}
            variants={reduce ? reducedVariants : panelVariants}
            initial="enter"
            animate="center"
            exit="exit"
            transition={reduce ? { duration: 0.01 } : swap}
            className="flex min-w-0 items-center pl-2"
          >
            {active.content}
          </motion.div>
        </AnimatePresence>
      </motion.div>
    </div>
  );
}

export default ContextualToolbar;

Technologies

FrameworkReact 19
MotionMotion 12 (motion/react), layout + AnimatePresence
StylingTailwind CSS v4
Iconslucide-react
TypesTypeScript 5.9, strict

Credits

Original design by nitishkmrk. Renamed from Contextual AI Bar — AI is one of the two modes, not what the pattern is. Rebuilt with a props API, a tabs contract, working controls and reduced-motion handling.

The two modes were also wired to the wrong icons: the music note opened the AI prompt and the sparkle opened the audio tools. Because each mode now carries its own content, the pairing is the consumer's to state and the demo states it the right way round.

The 110/10 resize spring — damping ratio 0.48, a pronounced overshoot — is unchanged. That give is what makes the bar read as one object reshaping rather than as two toolbars being swapped.