Anatomy of the Floating Toolbar
How one AnimatePresence and a shared spring turn a flat actions array into a toolbar that springs into place and lifts under the pointer.
↓Scroll to step through it
Anatomy
Floating Toolbar looks like a simple contextual bar, but it's built from one spring config reused three times: once for the whole shell mounting in, once for the shell leaving, and once per action button, per interaction. The component itself is small — the feel comes entirely from reusing that one number set consistently.
actions is a flat array, same shape philosophy as Context Menu — no
compound components, just objects the toolbar maps over. Structurally, an
"active" action is identical to an idle one; only aria-pressed and two
class names differ.
Every button — and the shell around them — carries layout. Add or remove
an action and Framer measures the before/after boxes and animates the diff,
so the row never jump-cuts when its contents change.
The motion
The shell doesn't just fade — AnimatePresence mounts and unmounts the
whole motion.div, and the enter is a stiff, barely-overshooting spring.
520/32 is a much stiffer, more critically-damped pair than the tooltip's
170/12/0.1 — this needs to feel immediate, like the toolbar was always
there and just became visible, not like it's still settling. The exit target
isn't the mirror of the enter (y: 8, not 12; scale: 0.95, not 0.92):
it travels a shorter distance on the way out, because a lingering overshoot
on dismissal would read as sluggish.
Per-action spring
The same 520/32 spring drives every action's hover and press state,
independent of the shell.
whileHover/whileTap are declarative targets, not event handlers you wire
up yourself — Framer owns the pointer listeners and animates toward whichever
state is currently true. Because it's the same spring as the shell's
entrance, a button lifting under the cursor feels like it's made of the same
material as the toolbar it's floating in, not a separate hover effect
layered on top.
The result
Two springs, one number set: stiffness: 520, damping: 32 gets the whole
shell in and out, and gets each button lifting and settling under the
pointer — reused, not reinvented, for every piece that moves.
- shell
- backdrop-blur pill, role=toolbar
- action
- an idle motion.button
- active
- aria-pressed, filled fill
<motion.div role="toolbar" aria-label="Floating toolbar" layout>{actions.map((action) => ( <motion.button key={action.label} layout aria-pressed={action.active} className={ action.active ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-accent hover:text-accent-foreground" } > {action.icon} </motion.button>))}</motion.div>Anatomy of the Floating Toolbar
How one AnimatePresence and a shared spring turn a flat actions array into a toolbar that springs into place and lifts under the pointer.
- shell
- backdrop-blur pill, role=toolbar
- action
- an idle motion.button
- active
- aria-pressed, filled fill
<motion.div role="toolbar" aria-label="Floating toolbar" layout>{actions.map((action) => ( <motion.button key={action.label} layout aria-pressed={action.active} className={ action.active ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-accent hover:text-accent-foreground" } > {action.icon} </motion.button>))}</motion.div>Anatomy
Floating Toolbar looks like a simple contextual bar, but it's built from one spring config reused three times: once for the whole shell mounting in, once for the shell leaving, and once per action button, per interaction. The component itself is small — the feel comes entirely from reusing that one number set consistently.
actions is a flat array, same shape philosophy as Context Menu — no
compound components, just objects the toolbar maps over. Structurally, an
"active" action is identical to an idle one; only aria-pressed and two
class names differ.
Every button — and the shell around them — carries layout. Add or remove
an action and Framer measures the before/after boxes and animates the diff,
so the row never jump-cuts when its contents change.
{ type: "spring", stiffness: 520, damping: 32 }
- enter
- opacity 0, y 12, scale 0.92 → spring 520/32
- exit
- opacity 0, y 8, scale 0.95 — a plain tween
- reduced motion
- collapses to an opacity-only fade
<AnimatePresence>{open && ( <motion.div initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 12, scale: 0.92 }} animate={{ opacity: 1, y: 0, scale: 1 }} exit={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 8, scale: 0.95 }} transition={{ type: "spring", stiffness: 520, damping: 32 }} > {/* actions */} </motion.div>)}</AnimatePresence>The motion
The shell doesn't just fade — AnimatePresence mounts and unmounts the
whole motion.div, and the enter is a stiff, barely-overshooting spring.
520/32 is a much stiffer, more critically-damped pair than the tooltip's
170/12/0.1 — this needs to feel immediate, like the toolbar was always
there and just became visible, not like it's still settling. The exit target
isn't the mirror of the enter (y: 8, not 12; scale: 0.95, not 0.92):
it travels a shorter distance on the way out, because a lingering overshoot
on dismissal would read as sluggish.
whileHover={ y: -2, scale: 1.08 } · whileTap={ scale: 0.94 }
- idle
- resting scale, no transform
- hover
- y: -2, scale: 1.08
- press
- whileTap scale: 0.94
<motion.buttonlayoutwhileHover={reduceMotion ? undefined : { y: -2, scale: 1.08 }}whileTap={reduceMotion ? undefined : { scale: 0.94 }}transition={{ type: "spring", stiffness: 520, damping: 32 }}>{action.icon}</motion.button>Per-action spring
The same 520/32 spring drives every action's hover and press state,
independent of the shell.
whileHover/whileTap are declarative targets, not event handlers you wire
up yourself — Framer owns the pointer listeners and animates toward whichever
state is currently true. Because it's the same spring as the shell's
entrance, a button lifting under the cursor feels like it's made of the same
material as the toolbar it's floating in, not a separate hover effect
layered on top.
The result
Two springs, one number set: stiffness: 520, damping: 32 gets the whole
shell in and out, and gets each button lifting and settling under the
pointer — reused, not reinvented, for every piece that moves.
Accessibility
role="toolbar" and aria-label="Floating toolbar" are hard-coded onto the
shell, and every action gets aria-label={action.label} plus
aria-pressed={action.active} — so a screen reader announces exactly which
tool is active without reading any visual state. useReducedMotion strips
every transform from both the shell and its actions, leaving nothing but the
opacity fades: the toolbar still appears and disappears, and buttons still
respond to hover/tap, just without the y-shift, scale, or overshoot.
Motion Score
opacityFade / cross-fade