Anchor interpolated morphing
A pure CSS technique combining anchor(), anchor-size(), @starting-style, and interpolate-size to create smooth contextual transitions from a trigger element to an overlay. Based on Adam Argyle's AIM technique.
<style>
:root {
--ease-3: cubic-bezier(0.25, 0, 0.3, 1);
--ease-spring-3: linear(
0,
0.009,
0.035 2.1%,
0.141,
0.281 6.7%,
0.723 12.9%,
0.938 16.7%,
1.017,
1.077,
1.121,
1.149 24.3%,
1.159,
1.163,
1.161,
1.154 29.9%,
1.129 32.8%,
1.051 39.6%,
1.017 43.1%,
0.991,
0.977 51%,
0.974 53.8%,
0.975 57.1%,
0.997 69.8%,
1.003 76.9%,
1.004 83.8%,
1
);
}
body {
margin: 0;
display: grid;
place-items: center;
min-height: 100vh;
font-family:
system-ui,
-apple-system,
sans-serif;
color: #2f2f37;
}
.triggers {
display: flex;
gap: 24px;
flex-wrap: wrap;
justify-content: center;
padding: 24px;
}
button {
font-family: inherit;
font-size: 15px;
font-weight: 500;
padding: 12px 24px;
color: #2f2f37;
cursor: pointer;
transition:
background 0.15s,
border-color 0.15s;
border: 1px solid #e0ddd8;
border-radius: 16px;
background: white;
box-shadow:
0 24px 48px rgba(0, 0, 0, 0.12),
0 4px 12px rgba(0, 0, 0, 0.06);
}
button:hover {
background: #f0efec;
border-color: #ccc9c2;
}
button:active {
scale: 0.97;
}
/* Each button is its own anchor, each dialog targets its trigger */
.trigger-1 {
anchor-name: --morph-1;
}
.trigger-2 {
anchor-name: --morph-2;
}
.trigger-3 {
anchor-name: --morph-3;
}
#dialog-1 {
position-anchor: --morph-1;
}
#dialog-2 {
position-anchor: --morph-2;
}
#dialog-3 {
position-anchor: --morph-3;
}
dialog {
--_size: min(420px, 90vw);
--_speed-in: 0.3s;
--_speed-out: 0.2s;
--_speed: var(--_speed-in);
--_anchor-radius: 16px;
/* polish+ dur, ease and delay for timing the dialog bg with the morph */
--_bg-transition: calc(var(--_speed) * 0.25) var(--ease-3) 0s;
/* polish+ top has different timing/easing when going in vs out */
--_top-transition: calc(var(--_speed) * 2.5) var(--ease-spring-3);
interpolate-size: allow-keywords;
margin: 0;
padding: 0;
border: none;
border-radius: 16px;
background: white;
color: #2f2f37;
box-shadow:
0 24px 48px rgba(0, 0, 0, 0.12),
0 4px 12px rgba(0, 0, 0, 0.06);
/* mask effect — clip content as dialog grows/shrinks */
overflow: clip;
position: fixed;
inset: unset;
/* ON STAGE position: near anchor with offset */
left: calc(anchor(left) - (var(--_size) * 0.25));
top: calc(anchor(top) - 25px);
@media (prefers-reduced-motion: no-preference) {
transition:
display var(--_speed) allow-discrete,
overlay var(--_speed) allow-discrete,
height var(--_speed) var(--ease-3),
width var(--_speed) var(--ease-3),
top var(--_top-transition),
right var(--_speed) var(--ease-3),
bottom var(--_speed) var(--ease-3),
left var(--_speed) var(--ease-3),
border-radius var(--_speed) var(--ease-3),
background var(--_bg-transition);
}
> .dialog-content {
/* fixed width for the mask/clip reveal effect */
inline-size: var(--_size);
@media (prefers-reduced-motion: no-preference) {
transition:
opacity var(--_speed) var(--ease-3),
transform var(--_speed) var(--ease-3);
}
/* slide in from top */
@starting-style {
transform: translateY(-50px);
}
}
/* EXIT STAGE TO — collapse back to anchor */
&:not([open]) {
left: anchor(left);
top: anchor(top);
right: anchor(right);
bottom: anchor(bottom);
width: anchor-size(width);
height: anchor-size(height);
background-color: #0000;
border-radius: var(--_anchor-radius);
/* outgoing timing mods */
--_speed: var(--_speed-out);
--_bg-transition: var(--_speed) var(--ease-3) calc(var(--_speed) * 0.5);
--_top-transition: var(--_speed) var(--ease-3);
> .dialog-content {
opacity: 0;
}
}
/* ENTER STAGE FROM — start at anchor shape */
@starting-style {
&[open] {
left: anchor(left);
top: anchor(top);
right: anchor(right);
bottom: anchor(bottom);
width: anchor-size(width);
height: anchor-size(height);
background-color: #0000;
border-radius: var(--_anchor-radius);
> .dialog-content {
opacity: 0;
}
}
}
&::backdrop {
opacity: 0;
}
}
.dialog-content {
padding: 24px;
}
.dialog-content h2 {
margin: 0 0 8px;
font-size: 18px;
font-weight: 600;
}
.dialog-content p {
margin: 0 0 16px;
font-size: 14px;
line-height: 1.6;
color: #737373;
}
.dialog-content form {
margin: 0;
}
.dialog-content .close-btn {
display: block;
width: 100%;
padding: 10px;
border-radius: 8px;
background: #2f2f37;
color: white;
border: none;
font-size: 14px;
font-weight: 500;
cursor: pointer;
box-shadow: none;
}
.dialog-content .close-btn:hover {
background: #1a1a1f;
}
.dialog-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.dialog-icon {
width: 40px;
height: 40px;
border-radius: 10px;
display: grid;
place-items: center;
font-size: 20px;
flex-shrink: 0;
}
.icon-purple {
background: #ede9fe;
}
.icon-blue {
background: #dbeafe;
}
.icon-amber {
background: #fef3c7;
}
</style>
<div class="triggers">
<button class="trigger-1" commandfor="dialog-1" command="show-modal">
Details
</button>
<button class="trigger-2" commandfor="dialog-2" command="show-modal">
Preview
</button>
<button class="trigger-3" commandfor="dialog-3" command="show-modal">
Settings
</button>
</div>
<dialog id="dialog-1" closedby="any">
<div class="dialog-content">
<div class="dialog-header">
<div class="dialog-icon icon-purple">📋</div>
<div><h2>Details</h2></div>
</div>
<p>
This dialog morphed from the button that triggered it using pure CSS
anchor positioning and interpolate-size. No JavaScript layout calculations
needed.
</p>
<form method="dialog"><button class="close-btn">Close</button></form>
</div>
</dialog>
<dialog id="dialog-2" closedby="any">
<div class="dialog-content">
<div class="dialog-header">
<div class="dialog-icon icon-blue">👁</div>
<div><h2>Preview</h2></div>
</div>
<p>
The entry and exit animations follow a curved path from the anchor
element, unlike View Transitions which interpolate in a straight line.
</p>
<form method="dialog"><button class="close-btn">Close</button></form>
</div>
</dialog>
<dialog id="dialog-3" closedby="any">
<div class="dialog-content">
<div class="dialog-header">
<div class="dialog-icon icon-amber">⚙️</div>
<div><h2>Settings</h2></div>
</div>
<p>
The animation is interruptible — click a different button while this
dialog is open and it will smoothly redirect to the new anchor position.
</p>
<form method="dialog"><button class="close-btn">Close</button></form>
</div>
</dialog>