Floating Toolbar Expanding from a Pill with Tool Options

A floating action toolbar for an editor, shown over a poster on a dark canvas. It rests as a small pill labelled Tools. Open it and the plus turns into a cross, the pill stretches, and the tools pop in one after another. Choosing a tool slides a highlight under it and swaps a second bar of options above. The demo plays by itself until the first click. On a phone it shows five tools so the row fits.

Code

import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useInView, useReducedMotion } from "framer-motion";
import { Frame, Image as ImageIcon, MessageSquare, MousePointer2, PenTool, Plus, Square, Type } from "lucide-react";

const tools = [
  { name: "Move", Icon: MousePointer2, options: ["Select", "Scale", "Hand"] },
  { name: "Frame", Icon: Frame, options: ["Phone", "Tablet", "Desktop"] },
  { name: "Text", Icon: Type, options: ["Heading", "Body", "Caption"] },
  { name: "Pen", Icon: PenTool, options: ["Pen", "Pencil", "Bend"] },
  { name: "Shape", Icon: Square, options: ["Rectangle", "Ellipse", "Polygon"] },
  { name: "Comment", Icon: MessageSquare, options: ["Note", "Mention"], wide: true },
  { name: "Image", Icon: ImageIcon, options: ["Upload", "Library"], wide: true },
];

/** The self-playing demo: each entry waits `after` milliseconds, then applies its change. It stops at the first click. */
const script: { after: number; open?: boolean; tool?: number }[] = [
  { after: 750, open: true },
  { after: 1450, tool: 2 },
  { after: 1400, tool: 4 },
  { after: 1400, tool: 3 },
  { after: 1900, open: false, tool: 0 },
  { after: 900 },
];

const css = `
@keyframes floating-toolbar-drift { 0%, 100% { transform: translate(0, 0); } 35% { transform: translate(-18px, 10px); } 70% { transform: translate(10px, -8px); } }
.floating-toolbar-drift { animation: floating-toolbar-drift 7s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) { .floating-toolbar-drift { animation: none; } }
`;

export default function FloatingToolbarExpandingFromPillWithToolOptions() {
  const reduce = useReducedMotion();
  const ref = useRef<HTMLElement>(null);
  const inView = useInView(ref, { amount: 0.3 });
  const [open, setOpen] = useState(!!reduce);
  const [tool, setTool] = useState(0);
  const [option, setOption] = useState(0);
  const [auto, setAuto] = useState(true);
  const [step, setStep] = useState(0);

  useEffect(() => {
    if (reduce) setOpen(true);
  }, [reduce]);

  useEffect(() => {
    if (!auto || reduce || !inView) return;
    const s = script[step % script.length];
    const id = window.setTimeout(() => {
      if (s.open !== undefined) setOpen(s.open);
      if (s.tool !== undefined) {
        setTool(s.tool);
        setOption(0);
      }
      setStep((n) => n + 1);
    }, s.after);
    return () => window.clearTimeout(id);
  }, [auto, step, inView, reduce]);

  const spring = reduce ? { duration: 0 } : { type: "spring" as const, stiffness: 340, damping: 30 };
  const active = tools[tool];

  return (
    <section ref={ref} className="relative isolate h-[620px] overflow-hidden bg-neutral-950 sm:h-[670px] text-white">
      <style>{css}</style>
      <div aria-hidden="true" className="absolute inset-0 -z-10" style={{ backgroundImage: "radial-gradient(rgba(255,255,255,0.13) 1px, transparent 1px)", backgroundSize: "22px 22px" }} />
      <div aria-hidden="true" className="absolute -bottom-40 left-1/2 -z-10 h-80 w-[640px] -translate-x-1/2 rounded-full bg-fuchsia-600/25 blur-3xl" />

      <div className="flex items-center justify-between gap-3 px-4 pt-4 sm:px-6 sm:pt-5">
        <h2 className="min-w-0 truncate rounded-full border border-white/10 bg-neutral-900/80 px-4 py-2 text-sm font-medium">
          <span className="text-neutral-400">Posters / </span>Night market
        </h2>
        <div className="flex flex-none items-center gap-2">
          <div className="flex -space-x-2">
            <span className="flex h-8 w-8 items-center justify-center rounded-full bg-fuchsia-500 text-xs font-bold ring-2 ring-neutral-950">RA</span>
            <span className="flex h-8 w-8 items-center justify-center rounded-full bg-amber-400 text-xs font-bold text-amber-950 ring-2 ring-neutral-950">JO</span>
          </div>
          <span className="hidden rounded-full border border-white/10 bg-neutral-900/80 px-3 py-2 font-mono text-xs text-neutral-300 sm:block">82%</span>
        </div>
      </div>

      {/* Artboard */}
      <div className="absolute left-1/2 top-[84px] w-[270px] -translate-x-1/2 sm:w-[300px]">
        <p className="mb-2 font-mono text-[11px] text-neutral-400">Poster, 300 x 380</p>
        <div className="relative h-[340px] overflow-hidden rounded-md bg-[#f4ede0] text-neutral-900 shadow-2xl shadow-black/60 sm:h-[380px]">
          <div aria-hidden="true" className="absolute -right-14 -top-14 h-48 w-48 rounded-full bg-fuchsia-500" />
          <div aria-hidden="true" className="absolute -left-10 bottom-16 h-32 w-32 rounded-full bg-amber-400" />
          <div aria-hidden="true" className="absolute bottom-0 left-0 right-0 h-20 bg-neutral-900" />
          <p className="absolute left-5 top-5 font-mono text-[11px] font-semibold uppercase tracking-widest">Vol. 12</p>
          <p className="absolute bottom-6 left-5 right-5 flex justify-between font-mono text-[11px] uppercase tracking-widest text-[#f4ede0]">
            <span>Fri 14 June</span>
            <span>Quay Street</span>
          </p>
        </div>
        {/* The selected layer */}
        <div className="absolute left-3 top-[150px] border-2 border-fuchsia-500 px-2 py-1 sm:top-[170px]">
          <p className="text-[44px] font-black uppercase leading-[0.9] tracking-tighter text-neutral-900 sm:text-5xl">
            Night
            <br />
            Market
          </p>
          {["-left-1.5 -top-1.5", "-right-1.5 -top-1.5", "-bottom-1.5 -left-1.5", "-bottom-1.5 -right-1.5"].map((p) => (
            <span key={p} aria-hidden="true" className={`absolute h-2.5 w-2.5 border-2 border-fuchsia-500 bg-white ${p}`} />
          ))}
          <span className="absolute -bottom-7 left-1/2 -translate-x-1/2 whitespace-nowrap rounded bg-fuchsia-500 px-1.5 py-0.5 font-mono text-[10px] font-semibold text-white">212 x 96</span>
        </div>
        {/* A collaborator's cursor that carries the current tool name */}
        <div aria-hidden="true" className="floating-toolbar-drift absolute -right-6 top-[236px] sm:-right-16">
          <svg viewBox="0 0 20 20" className="h-5 w-5 drop-shadow" fill="#f59e0b" stroke="#0a0a0a" strokeWidth="1.2">
            <path d="M3 2l13 6.5-5.6 1.7L8.5 16z" />
          </svg>
          <span className="ml-4 mt-0.5 flex h-6 items-center overflow-hidden rounded-full bg-amber-400 px-2.5 text-[11px] font-semibold text-amber-950">
            Jo,&nbsp;
            <AnimatePresence initial={false} mode="popLayout">
              <motion.span key={active.name} className="inline-block" initial={reduce ? false : { y: 12, opacity: 0 }} animate={{ y: 0, opacity: 1 }} exit={reduce ? { opacity: 0 } : { y: -12, opacity: 0 }} transition={{ duration: 0.2 }}>
                {active.name.toLowerCase()}
              </motion.span>
            </AnimatePresence>
          </span>
        </div>
      </div>

      {/* Floating toolbar */}
      <div className="absolute inset-x-0 bottom-6 flex flex-col items-center gap-2.5 px-3">
        <div className="flex h-11 items-center">
          <AnimatePresence mode="wait">
            {open && (
              <motion.div
                key={active.name}
                role="group"
                aria-label={`${active.name} options`}
                className="flex items-center gap-1 rounded-full border border-white/10 bg-neutral-900/90 p-1 shadow-xl shadow-black/50 backdrop-blur"
                initial={reduce ? false : { opacity: 0, y: 14, scale: 0.92 }}
                animate={{ opacity: 1, y: 0, scale: 1 }}
                exit={reduce ? { opacity: 0 } : { opacity: 0, y: 8, scale: 0.96, transition: { duration: 0.12 } }}
                transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 460, damping: 30 }}
              >
                {active.options.map((o, i) => (
                  <button
                    key={o}
                    type="button"
                    aria-pressed={option === i}
                    onClick={() => {
                      setAuto(false);
                      setOption(i);
                    }}
                    className={`min-h-[36px] rounded-full px-3.5 text-xs font-semibold transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-fuchsia-400 ${option === i ? "bg-white text-neutral-950" : "text-neutral-300 hover:text-white"}`}
                  >
                    {o}
                  </button>
                ))}
              </motion.div>
            )}
          </AnimatePresence>
        </div>

        <div role="toolbar" aria-label="Drawing tools" className="flex items-center rounded-full border border-white/10 bg-neutral-900/95 p-1.5 shadow-[0_18px_50px_-10px_rgba(217,70,239,0.55)] backdrop-blur">
          <button
            type="button"
            aria-expanded={open}
            aria-label={open ? "Close tools" : "Open tools"}
            onClick={() => {
              setAuto(false);
              setOpen((v) => !v);
            }}
            className="flex h-11 flex-none items-center rounded-full bg-white/10 px-3 text-sm font-semibold transition-colors hover:bg-white/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-fuchsia-400"
          >
            <motion.span className="flex" initial={false} animate={{ rotate: open ? 135 : 0 }} transition={spring}>
              <Plus aria-hidden="true" className="h-5 w-5" strokeWidth={2.4} />
            </motion.span>
            <motion.span aria-hidden="true" className="flex items-center overflow-hidden whitespace-nowrap" initial={false} animate={{ width: open ? 0 : "auto", opacity: open ? 0 : 1 }} transition={spring}>
              <span className="pl-2 pr-1">Tools</span>
              <kbd className="ml-1 rounded-md bg-white/10 px-1.5 py-0.5 font-mono text-[11px] font-medium text-neutral-300">T</kbd>
            </motion.span>
          </button>

          <AnimatePresence initial={false}>
            {open && (
              <motion.div key="tools" className="flex items-center overflow-hidden" initial={{ width: 0 }} animate={{ width: "auto" }} exit={{ width: 0 }} transition={spring}>
                <span aria-hidden="true" className="mx-2 h-6 w-px flex-none bg-white/15" />
                {tools.map((t, i) => {
                  const on = i === tool;
                  return (
                    <motion.button
                      key={t.name}
                      type="button"
                      aria-label={t.name}
                      aria-pressed={on}
                      onClick={() => {
                        setAuto(false);
                        setTool(i);
                        setOption(0);
                      }}
                      className={`relative h-11 w-11 flex-none items-center justify-center rounded-full focus:outline-none focus-visible:ring-2 focus-visible:ring-fuchsia-400 ${t.wide ? "hidden sm:flex" : "flex"} ${on ? "text-white" : "text-neutral-400 hover:text-white"}`}
                      initial={reduce ? false : { opacity: 0, scale: 0.3, y: 10 }}
                      animate={{ opacity: 1, scale: 1, y: 0 }}
                      exit={{ opacity: 0, scale: 0.5, transition: { duration: 0.1 } }}
                      transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 520, damping: 24, delay: 0.06 + i * 0.04 }}
                      whileTap={{ scale: 0.88 }}
                    >
                      {on && <motion.span layoutId="floating-toolbar-active" aria-hidden="true" className="absolute inset-0 rounded-full bg-fuchsia-600" transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 500, damping: 34 }} />}
                      <t.Icon aria-hidden="true" className="relative h-5 w-5" strokeWidth={2} />
                    </motion.button>
                  );
                })}
              </motion.div>
            )}
          </AnimatePresence>
        </div>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/floating-toolbar-expanding-from-pill-with-tool-options.json

When to use it

How to customize it

Accessibility

Questions

How does the pill expand smoothly?

The tools sit in a wrapper whose width Framer Motion animates from 0 to `auto` with a spring, with `overflow: hidden` so they are uncovered as it grows. The Tools label does the opposite. No widths are hard coded, so adding a tool just works.

How do the tools appear one by one?

Each tool button starts small, transparent and slightly low, and animates to full size with a spring. The delay is its position in the list times 0.04 seconds, which gives the ripple from left to right.

How does the highlight move between tools?

The coloured circle is rendered only inside the selected tool and has a `layoutId`. When the selection changes, Framer Motion animates it from the old button to the new one.

How do I place it over my own canvas?

Give your canvas `position: relative` and a height, and keep the toolbar wrapper's `absolute inset-x-0 bottom-6` classes. Avoid `position: fixed` unless the toolbar should stay on screen while the page scrolls.

Can the options bar be removed?

Yes. Delete the block labelled with the `options` map above the toolbar. The pill and the tool row do not depend on it.

Related: Magnifying Dock with Tooltips and Idle Wave, Dark Toast Notification Stack with Undo and Progress, Sticky Bottom Offer Bar.

More docks and floating menus or all free UI components. Start for free to have a full site built around it.

More in Docks

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap