Icon morph blueprint
Bookmark outline ↔ filled
A small save action becomes legible through a restrained fill, then holds a clear saved state.
Build it
Choose a target and adapt the source to your interface.
<button
class="selection-control"
type="button"
aria-pressed="false"
aria-label="Bookmark outline ↔ filled"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path class="selection-fill" d="M7 3h10v18l-5-3-5 3Z" />
<path class="selection-outline" d="M7 3h10v18l-5-3-5 3Z" />
</svg>
</button>
<script>
const control = document.querySelector('.selection-control');
control.addEventListener('click', () =>
control.setAttribute(
'aria-pressed',
String(control.getAttribute('aria-pressed') !== 'true'),
),
);
</script>
<style>
.selection-control {
display: inline-grid;
place-items: center;
width: 44px;
height: 44px;
padding: 10px;
border: 0;
border-radius: 6px;
background: transparent;
color: #175bc5;
cursor: pointer;
}
.selection-control svg {
width: 24px;
height: 24px;
overflow: visible;
}
.selection-control:focus-visible {
outline: 2px solid currentColor;
outline-offset: 4px;
}
.selection-outline {
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linejoin: round;
}
.selection-fill {
fill: currentColor;
clip-path: inset(0 0 100%);
transition: clip-path 150ms cubic-bezier(0.16, 1, 0.3, 1);
}
.selection-control[aria-pressed='true'] .selection-fill {
clip-path: inset(0);
transition-delay: 90ms;
}
.selection-control[aria-pressed='true'] svg {
animation: selection-pop 240ms ease-out;
}
@keyframes selection-pop {
0%,
100% {
transform: scale(1);
}
35% {
transform: scale(1.08);
}
}
@media (prefers-reduced-motion: reduce) {
.selection-control *,
.selection-control[aria-pressed='true'] * {
animation: none;
transition: none;
}
}
</style>Animation breakdown
Frame phases, motion intent, implementation constraints and accessible alternatives.
Rest: outlined bookmark.
0–90ms: icon scales to 1.08.
90–190ms: fill sweeps down while the icon returns to scale 1.
190–240ms: hold the filled state.
Motion intent
A small save action becomes legible through a restrained fill, then holds a clear saved state.
This duration leaves enough time to read the state change while keeping the interface responsive.
The fast acceleration and long ease-out make the state feel responsive while giving the final pixels time to settle.
Motion specification
- Trigger
- A user saves or removes an item.
- Timing
- 240ms
- Easing
- cubic-bezier(.16, 1, .3, 1)
Animated properties
Implementation notes
- Keep save and saved aligned to the same visual anchor.
- Drive the transition from semantic state, not a timer, when the trigger is interactive.
- Use transform, opacity and avoid layout work inside the animation loop.
Reduced motion
Swap to the final state instantly, preserving colour, label, and accessible state.
- Use a minimum 44 by 44 pixel interactive target.
- Expose the save and saved states with aria-pressed, aria-expanded, or an updated accessible label.
- Keep visible keyboard focus independent from the animation.
Avoid
- Do not add bounce unless a real object pickup or spring settle is part of the interaction.
- Do not hide the final state from assistive technology.
- Do not make the user wait for animation before the underlying state updates.