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 from reusing that one number set everywhere.
actions is a flat array, the same approach as Context Menu: plain objects
the toolbar maps over instead of compound components. 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
AnimatePresence mounts and unmounts the whole motion.div, and the
enter springs opacity, y and scale on a stiff, barely-overshooting
spring.
520/32 is a much stiffer, more critically damped pair than the tooltip's
170/12/0.1. The toolbar should feel immediate, as if it was always
there and just became visible. The exit target doesn't mirror 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 rather than 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 moves like part of the
toolbar it floats in instead of a separate hover effect layered on top.
The result
Two springs, one number set: stiffness: 520, damping: 32 moves the whole
shell in and out, and lifts and settles each button 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 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 from reusing that one number set everywhere.
actions is a flat array, the same approach as Context Menu: plain objects
the toolbar maps over instead of compound components. 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
AnimatePresence mounts and unmounts the whole motion.div, and the
enter springs opacity, y and scale on a stiff, barely-overshooting
spring.
520/32 is a much stiffer, more critically damped pair than the tooltip's
170/12/0.1. The toolbar should feel immediate, as if it was always
there and just became visible. The exit target doesn't mirror 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 rather than 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 moves like part of the
toolbar it floats in instead of a separate hover effect layered on top.
The result
Two springs, one number set: stiffness: 520, damping: 32 moves the whole
shell in and out, and lifts and settles each button under the pointer.
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