IconSwap
Transition between icons to show a change of state, including morphing one shape into another.
Use it when a control's two states are two different icons: notify and muted, invite and invited, a file and the link that opens it. It takes Youj icons and lucide-react icons alike. When a state icon already covers the pair, use that instead: its morph is choreographed for those two states.
Installation
npm i @youj/iconsFor an app already set up with npx @youj/cli init, which installs the peers (@youj/core, @youj/motion, motion and Base UI) and wires the provider. New app? Start at Installation.
Usage
Give it the two icons and a state. active={false} shows from, true shows to.
import { BellIcon, BellOffIcon, IconSwap } from '@youj/icons'
<IconSwap from={BellIcon} to={BellOffIcon} active={muted} transition="morph" />Pass the components, not elements. Youj icons are held still inside the swap, so only the transition moves.
Examples
Transitions
Every transition is a pure function of one progress value, from 0 (showing from) to 1 (showing to). Toggling mid-swap reverses from exactly where it is, and rapid toggles never jump. The default is scale-blur.
| Transition | What it does |
|---|---|
scale-blur | The old icon shrinks out of focus as the new one sharpens in. |
rotate | One continuous quarter turn: out turns away as in turns in from the other side. |
morph | A true path morph from one icon's strokes to the other's. |
draw | The old strokes run out through their ends, then the new strokes draw on. |
flip | A card turned on its vertical axis. |
fold | Hinged flaps: out folds back on its bottom edge, in swings down from the top. |
slide | One strip: the new icon pushes the old one out through the edge of the box. |
wipe | A hard edge sweeps across, with the new icon behind it. |
zoom | Out flies past the viewer, in arrives from far away. |
pop | Out shrinks away, in overshoots once and settles. Keep it for success. |
direction steers slide (default up) and wipe (default right). Left and right follow the reading direction, so they swap under dir="rtl".
Chain up to ten icons in any transition, and copy the code, in Chain.
Driving the progress
Instead of a state, pass progress: a number from 0 (showing from) to 1 (showing to), or a MotionValue, which repaints on every change without a render. The swap holds that moment of the transition, so a scrubber, a drag or the scroll can drive it. While progress is set, active and trigger are ignored, and labels name whichever icon it's nearer.
import { useScroll } from 'motion/react'
import { ArrowDownIcon, CheckIcon, IconSwap } from '@youj/icons'
const { scrollYProgress } = useScroll()
<IconSwap from={ArrowDownIcon} to={CheckIcon} transition="morph" progress={scrollYProgress} />It's painted as given under reduced motion too: the value is yours, and the swap adds no motion of its own. If the scroll drives it, decide what reduced motion should show, for example progress={reduced ? 1 : scrollYProgress}.
True morph
morph reshapes one icon into another, even two that share nothing, with no dependency. Each icon's strokes are flattened into contours. Strokes the two icons share hold still, and a stroke cut by a slash is carved out of the original, so a bell loses a gap exactly where the slash lands. The remaining strokes pair up by position and length and bend into place, turning rather than collapsing. Anything left over draws on or retracts from where it meets the rest of the icon.
The plan for each pair of icons is worked out once and shared by every instance.
On hover
Leave active out and the swap follows its trigger, hover by default. Like any Youj icon, it listens to its closest button, link or data-yj-trigger parent, so a row can reveal the action it will take.
With Lucide icons
from and to also accept Lucide icons, including for morph transitions. This optional integration needs lucide-react installed in your app. Here Eye changes to EyeOff as the lock opens.
import { Eye, EyeOff } from 'lucide-react'
import { IconSwap } from '@youj/icons'
<button type="button" aria-label="Show secret" aria-pressed={shown} onClick={() => setShown(!shown)}>
<IconSwap from={Eye} to={EyeOff} active={shown} transition="morph" />
</button>On its own
A swap that stands alone and means something needs a name for each state. labels gives it role="img" and names it for the current state.
<IconSwap from={CloudIcon} to={CheckCircleIcon} active={saved} labels={['Saving', 'Saved']} transition="pop" />Without labels the swap is aria-hidden, which is right inside a labeled button.
Reference
Propsfrom, to, active… (12)
Props
| Prop | Type | |
|---|---|---|
fromRequired | SwapIconShown while inactive. | – |
toRequired | SwapIconShown while active. | – |
active | booleanControlled state: false shows | – |
trigger | IconTrigger'hover''mount''loop''none'What swaps an uncontrolled IconSwap. default 'hover' | 'hover' |
progress | number | MotionValue<number>Drives the swap directly instead of a state: 0 shows | – |
transition | IconSwapTransition'rotate''scale-blur''draw''flip''slide''fold''wipe''zoom''pop''morph'default 'scale-blur' | 'scale-blur' |
direction | IconSwapDirection'up''down''left''right'Travel direction for | – |
labels | readonly [string, string]Accessible names for [from, to]. Without them the swap is decorative (aria-hidden), e.g. inside a labeled button. | – |
size | number | stringdefault 24 | 24 |
strokeWidth | numberdefault 1.5 | 1.5 |
className | string | – |
style | CSSProperties | – |
MotionScale-blur, rotate, zoom, slide, wipe, flip, fold… · reduced motion
Motion
| Moment | Tokens | ||
|---|---|---|---|
scale-blur, rotate, zoomout on exit, in on enter | ease.entercubic-bezier(0.16, 1, 0.3, 1) · slow + fast: 480ms | cubic-bezier(0.16, 1, 0.3, 1)slow + fast: 480ms | |
| slide, wipeone push across the box | ease.emphasizedcubic-bezier(0.6, 0, 0.1, 1) · slow + fast: 480ms | cubic-bezier(0.6, 0, 0.1, 1)slow + fast: 480ms | |
| flip, foldout to edge-on, then in | duration.deliberateease.enter560ms · cubic-bezier(0.16, 1, 0.3, 1) | 560mscubic-bezier(0.16, 1, 0.3, 1) | |
| popin overshoots to 114%, then settles | duration.deliberateease.enter560ms · cubic-bezier(0.16, 1, 0.3, 1) | 560mscubic-bezier(0.16, 1, 0.3, 1) | |
| drawstrokes retract, then new ones draw on | duration.deliberateease.enter560ms · cubic-bezier(0.16, 1, 0.3, 1) | 560mscubic-bezier(0.16, 1, 0.3, 1) | |
| morphstrokes bend into place | ease.emphasizedcubic-bezier(0.6, 0, 0.1, 1) · deliberate + fast: 700ms | cubic-bezier(0.6, 0, 0.1, 1)deliberate + fast: 700ms | |
| Reversaltakes the remaining share of the length | no token length × distance left | length × distance left |
Reduced motion
The swap is instant: the new icon replaces the old one with no transition.
Reduced motion
The swap switches instantly to the icon for the new state. Nothing moves, and the state reads the same.
AccessibilityKeyboard, ARIA, focus
Accessibility
- Without
labels, the swap is decorative (aria-hidden). Inside a button, put the name and the state on the button, witharia-pressedor a name that changes, as useIconState does. - With
labels, it'srole="img"named for its current state. A new name isn't announced on its own, so pair a change that matters with a status message. - The swap never takes focus, and its hover trigger also answers keyboard focus on the parent.