This is a practical front-end article for developers researching the View Transitions API, shared-element transitions, native <dialog>, portfolio interactions, modal animation, and lightweight alternatives to GSAP Flip.
The important idea is not simply that the modal animates.
It is that the interface preserves visual identity between two states.
A Project Card That Becomes the Modal: View Transitions Without Animation Plumbing #
There is a subtle UX problem with a lot of modal interfaces.
You click this:
┌─────────────────┐
│ │
│ PROJECT │
│ │
└─────────────────┘
and suddenly this appears:
┌──────────────────────────────────┐
│ │
│ PROJECT DETAILS │
│ │
└──────────────────────────────────┘
We understand what happened logically.
But visually, the relationship is weak.
The card disappeared. A completely different object appeared above the page.
A fade can make that softer:
card
↓
fade out
modal
↓
fade in
but it still describes the interaction as one thing disappearing and another thing arriving.
What I wanted instead was:
CARD
↓
expands
↓
reorganizes
↓
DIALOG
The user should perceive:
this is still the same project; I am simply seeing more of it.
That is exactly the kind of interaction where the View Transitions API starts becoming genuinely useful.
Not because it gives us another animation API to play with.
Because it can remove a surprising amount of animation plumbing.
Why I Started Looking at State Transitions #
One pattern in the August 18–21 design feed caught my attention: several interesting sites were putting substantial effort into transitions between interface states, not just isolated hover effects.
Awwwards’ Cipher from Magnetism, dated August 20 in my research notes, highlights transitions, its loader, and contact transitions among the site’s notable details.
Michael Gatt, by Synchronized Studio, appeared on August 18 with an interactive loader, scroll storytelling, and player/equalizer interactions.
At the quieter end of the spectrum, Aspen Search on CSS Design Awards is much more typographic, minimal and grid-based.
That contrast is useful.
Modern interaction does not automatically mean:
install renderer
load 3D model
add shaders
warm GPU
Sometimes the more interesting improvement is simply making state changes spatially understandable.
Codrops’ Webzibition was also feeding that broader line of research, while the Russian Web Standards channel surfaced a particularly relevant experiment: transforming a <button> into a <dialog> with View Transitions.
That was the rabbit hole.
Live CodePen #
Here is my working version:
The useful thing to watch is not the fade.
Watch the geometry.
The card and its image maintain visual continuity while the interface switches from grid item to modal.
The Architecture #
The basic model is:
PROJECT CARD
│
│ view-transition-name: project-panel
│
▼
DOM STATE CHANGE
│
▼
NATIVE DIALOG
│
│ view-transition-name: project-panel
▼
PROJECT MODAL
The image gets its own identity:
project-image
so the container and image can animate independently.
The browser handles the interpolation between the old and new visual states.
HTML #
<section class="projects">
<header class="projects__header">
<span class="projects__eyebrow">Selected / 2026</span>
<h2>
Projects<br>
<span>worth opening.</span>
</h2>
</header>
<div class="projects__grid">
<article
class="project-card"
data-project
data-id="architecture"
>
<button
class="project-card__trigger"
type="button"
data-project-open
aria-haspopup="dialog"
aria-expanded="false"
>
<div class="project-card__media">
<img
src="https://images.unsplash.com/photo-1486406146926-c627a92ad1ab?auto=format&fit=crop&w=1200&q=80"
alt="Urban Architecture project preview"
width="1200"
height="800"
loading="lazy"
>
</div>
<div class="project-card__content">
<span class="project-card__number">01</span>
<div>
<h3>Urban Architecture</h3>
<p>Branding, design & development</p>
</div>
<span
class="project-card__arrow"
aria-hidden="true"
>
↗
</span>
</div>
</button>
</article>
<article
class="project-card"
data-project
data-id="product"
>
<button
class="project-card__trigger"
type="button"
data-project-open
aria-haspopup="dialog"
aria-expanded="false"
>
<div class="project-card__media">
<img
src="https://images.unsplash.com/photo-1523275335684-37898b6baf30?auto=format&fit=crop&w=1200&q=80"
alt="Product System project preview"
width="1200"
height="800"
loading="lazy"
>
</div>
<div class="project-card__content">
<span class="project-card__number">02</span>
<div>
<h3>Product System</h3>
<p>Strategy, UX & product</p>
</div>
<span
class="project-card__arrow"
aria-hidden="true"
>
↗
</span>
</div>
</button>
</article>
<article
class="project-card"
data-project
data-id="property"
>
<button
class="project-card__trigger"
type="button"
data-project-open
aria-haspopup="dialog"
aria-expanded="false"
>
<div class="project-card__media">
<img
src="https://images.unsplash.com/photo-1600585154340-be6161a56a0c?auto=format&fit=crop&w=1200&q=80"
alt="Private Residence project preview"
width="1200"
height="800"
loading="lazy"
>
</div>
<div class="project-card__content">
<span class="project-card__number">03</span>
<div>
<h3>Private Residence</h3>
<p>Property site & campaign</p>
</div>
<span
class="project-card__arrow"
aria-hidden="true"
>
↗
</span>
</div>
</button>
</article>
</div>
<dialog
class="project-modal"
data-project-modal
aria-labelledby="project-modal-title"
>
<div class="project-modal__inner">
<header class="project-modal__header">
<span
class="project-modal__number"
data-modal-number
></span>
<button
type="button"
class="project-modal__close"
data-project-close
aria-label="Close"
>
×
</button>
</header>
<div
class="project-modal__media"
data-modal-media
></div>
<div class="project-modal__content">
<div>
<span class="project-modal__label">
Case study
</span>
<h3
id="project-modal-title"
data-modal-title
></h3>
</div>
<div class="project-modal__description">
<p data-modal-description></p>
<a href="#" class="project-modal__link">
View project
<span aria-hidden="true">→</span>
</a>
</div>
</div>
</div>
</dialog>
</section>
The interaction starts from a real button and ends in a real <dialog>.
That matters more than the animation.
CSS #
.projects {
--bg: #efeee8;
--text: #121310;
--muted: #777870;
--line: rgba(18, 19, 16, .14);
--accent: #d7ff43;
--scrollbar-track: rgba(255, 255, 255, .06);
--scrollbar-thumb: rgba(255, 255, 255, .28);
--scrollbar-thumb-hover: rgba(255, 255, 255, .45);
padding:
clamp(70px, 9vw, 150px)
clamp(20px, 5vw, 80px);
background: var(--bg);
color: var(--text);
font-family: 'Inter', system-ui, -apple-system, sans-serif;
}
*, *::before, *::after {
box-sizing: border-box;
}
.projects a:focus-visible,
.projects button:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 3px;
border-radius: 2px;
}
.projects__header {
max-width: 1400px;
margin: 0 auto clamp(50px, 8vw, 110px);
}
.projects__eyebrow {
display: block;
margin-bottom: 24px;
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 11px;
letter-spacing: .12em;
text-transform: uppercase;
color: var(--muted);
}
.projects__header h2 {
margin: 0;
font-size: clamp(54px, 8vw, 130px);
line-height: .85;
letter-spacing: -.065em;
font-weight: 500;
}
.projects__header h2 span {
color: var(--muted);
}
.projects__grid {
display: grid;
grid-template-columns: repeat(12, 1fr);
gap: clamp(12px, 2vw, 30px);
max-width: 1400px;
margin-inline: auto;
}
.project-card:nth-child(1) {
grid-column: span 7;
}
.project-card:nth-child(2) {
grid-column: 9 / span 4;
margin-top: 14vw;
}
.project-card:nth-child(3) {
grid-column: 3 / span 6;
margin-top: 6vw;
}
.project-card__trigger {
display: block;
width: 100%;
padding: 0;
border: 0;
background: transparent;
color: inherit;
text-align: inherit;
cursor: pointer;
}
.project-card__media {
position: relative;
overflow: hidden;
aspect-ratio: 1.45;
border-radius: 3px;
background: #d8d8d1;
}
.project-card__media img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
transform: scale(1.001);
transition:
transform 700ms cubic-bezier(.2, .7, .2, 1);
}
.project-card:hover .project-card__media img,
.project-card__trigger:focus-visible .project-card__media img {
transform: scale(1.035);
}
.project-card__content {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 20px;
align-items: start;
padding-top: 16px;
}
.project-card__number {
padding-top: 6px;
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 10px;
color: var(--muted);
}
.project-card h3 {
margin: 0 0 5px;
font-size: clamp(22px, 2vw, 34px);
line-height: 1;
font-weight: 500;
letter-spacing: -.035em;
}
.project-card p {
margin: 0;
font-size: 13px;
color: var(--muted);
}
.project-card__arrow {
display: grid;
place-items: center;
width: 36px;
aspect-ratio: 1;
border: 1px solid var(--line);
border-radius: 50%;
transition:
transform 250ms ease,
background 250ms ease;
}
.project-card:hover .project-card__arrow,
.project-card__trigger:focus-visible .project-card__arrow {
transform: rotate(45deg);
background: var(--accent);
}
/* Dialog */
.project-modal {
width: min(1200px, calc(100vw - 40px));
max-width: none;
max-height: calc(100vh - 40px);
padding: 0;
border: 0;
border-radius: 4px;
background: #10110f;
color: #f2f2ea;
overflow: hidden;
}
.project-modal::backdrop {
background: rgba(8, 8, 7, .72);
backdrop-filter: blur(8px);
}
.project-modal__inner {
max-height: calc(100vh - 40px);
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
}
.project-modal__inner::-webkit-scrollbar {
width: 10px;
}
.project-modal__inner::-webkit-scrollbar-track {
background: var(--scrollbar-track);
}
.project-modal__inner::-webkit-scrollbar-thumb {
background-color: var(--scrollbar-thumb);
border-radius: 999px;
border: 2px solid transparent;
background-clip: content-box;
}
.project-modal__inner::-webkit-scrollbar-thumb:hover {
background-color: var(--scrollbar-thumb-hover);
}
.project-modal__header {
display: flex;
justify-content: space-between;
align-items: center;
padding:
18px
clamp(20px, 3vw, 40px);
border-bottom: 1px solid rgba(255, 255, 255, .12);
}
.project-modal__number {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 11px;
color: #8b8d85;
}
.project-modal__close {
display: grid;
place-items: center;
width: 42px;
aspect-ratio: 1;
padding: 0;
border: 1px solid rgba(255, 255, 255, .15);
border-radius: 50%;
background: transparent;
color: white;
font-size: 25px;
line-height: 1;
cursor: pointer;
transition:
background 200ms ease,
border-color 200ms ease;
}
.project-modal__close:hover {
background: rgba(255, 255, 255, .08);
border-color: rgba(255, 255, 255, .3);
}
.project-modal__media {
aspect-ratio: 2.15;
overflow: hidden;
background: #222;
}
.project-modal__media img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.project-modal__content {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 50px;
padding:
clamp(34px, 5vw, 70px)
clamp(22px, 5vw, 70px);
}
.project-modal__label {
display: block;
margin-bottom: 18px;
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 10px;
letter-spacing: .12em;
text-transform: uppercase;
color: var(--accent);
}
.project-modal h3 {
max-width: 550px;
margin: 0;
font-size: clamp(42px, 6vw, 90px);
line-height: .9;
letter-spacing: -.055em;
font-weight: 500;
}
.project-modal__description {
align-self: end;
}
.project-modal__description p {
max-width: 450px;
margin: 0;
font-size: clamp(16px, 1.3vw, 20px);
line-height: 1.7;
color: #969990;
}
.project-modal__link {
display: inline-flex;
gap: 12px;
align-items: center;
margin-top: 30px;
padding-bottom: 5px;
border-bottom: 1px solid currentColor;
color: white;
text-decoration: none;
}
.project-modal__link span {
transition: transform 200ms ease;
}
.project-modal__link:hover span {
transform: translateX(4px);
}
/* View Transition */
.project-card.is-transitioning {
view-transition-name: project-panel;
}
.project-card.is-transitioning .project-card__media {
view-transition-name: project-image;
}
.project-modal.is-transitioning {
view-transition-name: project-panel;
}
.project-modal.is-transitioning .project-modal__media {
view-transition-name: project-image;
}
::view-transition-group(project-panel) {
animation-duration: 520ms;
animation-timing-function:
cubic-bezier(.22, .75, .2, 1);
}
::view-transition-group(project-image) {
animation-duration: 620ms;
animation-timing-function:
cubic-bezier(.22, .75, .2, 1);
}
::view-transition-old(project-image),
::view-transition-new(project-image) {
width: 100%;
height: 100%;
object-fit: cover;
animation-duration: inherit;
}
/* Mobile */
@media (max-width: 760px) {
.projects__grid {
display: block;
}
.project-card,
.project-card:nth-child(1),
.project-card:nth-child(2),
.project-card:nth-child(3) {
margin: 0 0 45px;
}
.project-card__media {
aspect-ratio: 1.15;
}
.project-modal {
width: calc(100vw - 20px);
max-height: calc(100vh - 20px);
}
.project-modal__inner {
max-height: calc(100vh - 20px);
}
.project-modal__media {
aspect-ratio: 1.25;
}
.project-modal__content {
grid-template-columns: 1fr;
gap: 32px;
}
}
/* Motion accessibility */
@media (prefers-reduced-motion: reduce) {
.project-card__media img,
.project-card__arrow,
.project-modal__link span {
transition: none;
}
::view-transition-group(project-panel),
::view-transition-group(project-image) {
animation-duration: 0s;
}
}
The two important names are:
project-panel
project-image
They are the identities that survive the DOM state change.
Vanilla JavaScript #
<script>
(() => {
const cards = [...document.querySelectorAll('[data-project]')];
const modal = document.querySelector('[data-project-modal]');
if (!cards.length || !modal) {
return;
}
const closeButton = modal.querySelector('[data-project-close]');
const modalMedia = modal.querySelector('[data-modal-media]');
const modalNumber = modal.querySelector('[data-modal-number]');
const modalTitle = modal.querySelector('[data-modal-title]');
const modalDescription = modal.querySelector(
'[data-modal-description]'
);
const prefersReducedMotion = window.matchMedia(
'(prefers-reduced-motion: reduce)'
);
let activeCard = null;
let activeTrigger = null;
/**
* Guards against overlapping transitions.
*/
let activeTransition = null;
/**
* Content can later come from a CMS.
*/
const projectContent = {
architecture: {
description:
'A digital system for an architectural project, built around ' +
'clear hierarchy, typography and a browsing experience that ' +
'carries the visitor through the story.'
},
product: {
description:
'A product experience that connects technical information, ' +
'clear design and a path to action without unnecessary friction.'
},
property: {
description:
'A property page built around what actually matters: the ' +
'project itself, its location, its advantages, and a clear ' +
'path to enquiry.'
}
};
/**
* Progressive View Transition enhancement.
*/
function runTransition(update) {
if (
!document.startViewTransition ||
prefersReducedMotion.matches
) {
update();
return null;
}
if (activeTransition) {
activeTransition.skipTransition?.();
}
const transition =
document.startViewTransition(update);
activeTransition = transition;
transition.finished
.catch(() => {})
.finally(() => {
if (activeTransition === transition) {
activeTransition = null;
}
});
return transition;
}
/**
* Copy card information into the dialog.
*/
function populateModal(card) {
const image = card.querySelector(
'.project-card__media img'
);
const title = card.querySelector('h3');
const number = card.querySelector(
'.project-card__number'
);
const id = card.dataset.id;
const imageClone = image.cloneNode();
imageClone.removeAttribute('loading');
imageClone.decoding = 'async';
imageClone.alt = image.alt;
modalMedia.replaceChildren(imageClone);
modalTitle.textContent = title.textContent;
modalNumber.textContent = number.textContent;
modalDescription.textContent =
projectContent[id]?.description || '';
}
/**
* Open project dialog.
*/
function openProject(card, trigger) {
activeCard = card;
activeTrigger = trigger;
trigger.setAttribute(
'aria-expanded',
'true'
);
populateModal(card);
card.classList.add(
'is-transitioning'
);
const transition = runTransition(() => {
card.classList.remove(
'is-transitioning'
);
modal.classList.add(
'is-transitioning'
);
modal.showModal();
});
const cleanup = () =>
modal.classList.remove(
'is-transitioning'
);
if (transition) {
transition.finished
.catch(() => {})
.finally(cleanup);
} else {
cleanup();
}
}
/**
* Close dialog back into the originating card.
*/
function closeProject() {
if (!activeCard) {
modal.close();
return;
}
const previousCard = activeCard;
const previousTrigger = activeTrigger;
modal.classList.add(
'is-transitioning'
);
const transition = runTransition(() => {
modal.classList.remove(
'is-transitioning'
);
previousCard.classList.add(
'is-transitioning'
);
modal.close();
});
const cleanup = () => {
previousCard.classList.remove(
'is-transitioning'
);
previousTrigger?.setAttribute(
'aria-expanded',
'false'
);
previousTrigger?.focus();
};
if (transition) {
transition.finished
.catch(() => {})
.finally(cleanup);
} else {
cleanup();
}
activeCard = null;
activeTrigger = null;
}
cards.forEach(card => {
const trigger = card.querySelector(
'[data-project-open]'
);
trigger.addEventListener(
'click',
() => {
openProject(card, trigger);
}
);
});
closeButton.addEventListener(
'click',
closeProject
);
/**
* Animate native Escape-key closing.
*/
modal.addEventListener(
'cancel',
event => {
event.preventDefault();
closeProject();
}
);
/**
* Optional light-dismiss.
*/
modal.addEventListener(
'click',
event => {
if (event.target === modal) {
closeProject();
}
}
);
})();
</script>
What the Browser Is Doing for Us #
Before View Transitions, I would probably approach the morph roughly like this:
const from = card.getBoundingClientRect();
const to = modal.getBoundingClientRect();
Then comes the familiar machinery:
measure card
↓
clone it
↓
position clone fixed
↓
calculate delta
↓
animate dimensions
↓
hide original
↓
reveal modal
↓
remove clone
↓
reverse everything on close
Congratulations: we now have a small municipal animation department.
The View Transitions API moves much of that responsibility into the browser.
Conceptually, document.startViewTransition() captures the old visual state, performs our DOM update, captures the new state, and lets elements carrying corresponding view-transition-name values participate in the same transition.
Reference:
https://developer.mozilla.org/en-US/docs/Web/API/Document/startViewTransition
Chrome’s same-document guide is also useful:
https://developer.chrome.com/docs/web-platform/view-transitions/same-document
In this component we effectively tell the browser:
view-transition-name: project-panel;
before the change, and:
view-transition-name: project-panel;
after the change.
In human terms:
THIS
↓
is still
↓
THIS
That is the interesting part.
Why Native <dialog> Matters #
The View Transition is only an enhancement.
The actual interface is based on:
<dialog>
and:
modal.showModal();
If the View Transitions API is unavailable, this branch runs:
if (!document.startViewTransition) {
update();
}
The dialog still opens.
There is simply no morph.
That is exactly the kind of progressive enhancement I like: removing the animation should not destroy the interaction.
Using the native dialog also means the component starts from an element designed for modal interaction rather than from:
<div class="modal">
plus a pile of custom behavior pretending it is a dialog.
Why This Demo Looks Slightly Overbuilt #
The CodePen contains more code than the minimum required to demonstrate startViewTransition().
That is intentional.
For a five-minute experiment you could remove a fair amount:
responsive refinements
reduced-motion handling
transition collision protection
focus restoration
aria-expanded state
custom scrollbar styling
Escape interception
backdrop dismissal
defensive cleanup
and the central demo would still work.
Something close to the minimum could be tiny:
document.startViewTransition(() => {
modal.showModal();
});
But that is not the version I would use to evaluate whether an interaction is suitable for a real project.
The longer version answers less glamorous questions:
What if somebody clicks quickly?
What happens when the transition is interrupted?
What happens without View Transitions?
Where does focus return after closing?
What happens when the user requests reduced motion?
What happens at 390px wide?
That is why the sample may look slightly excessive.
It is closer to a production-ready test bed than a code-golf demonstration.
For experiments, presentations, or learning the API, strip it down aggressively.
For an actual component, keep the parts your users need and adapt everything else to the project.
Accessibility Belongs in the First Version #
This is also why I prefer putting accessibility into experimental components early.
The trigger already says:
aria-haspopup="dialog"
aria-expanded="false"
The close control is a real button:
<button
type="button"
aria-label="Close"
>
Decorative arrows are hidden:
aria-hidden="true"
Keyboard focus is visible:
:focus-visible {
outline: 2px solid var(--accent);
}
Reduced-motion preference disables the visual transition, and when the modal closes, focus returns to the control that opened it:
previousTrigger?.focus();
That last detail is easy to forget if accessibility gets postponed until after the animation is “finished.”
My rule is increasingly simple:
do not plan to add accessibility later. Put the semantics, focus behavior, motion preferences, and responsive fallback into the component while you still understand its state machine.
It is much easier than revisiting a finished animation and asking where all of those behaviors can be inserted without breaking it.
Where I Would Actually Use This #
Portfolio work is the obvious example:
project card
↓
case study preview
But the pattern generalizes nicely:
person
↓
team biography
apartment
↓
property details
product
↓
quick view
service
↓
expanded description
vacancy
↓
job details
WooCommerce is particularly tempting:
┌─────────────┐
│ PRODUCT │
│ ₪299 │
└─────────────┘
↓
↓ morph
↓
┌──────────────────────────┐
│ IMAGE variations │
│ sizes │
│ ₪299 │
│ ADD TO CART │
└──────────────────────────┘
A Quick View stops feeling like a popup placed on top of the shop and starts feeling like the product card itself has expanded into its detailed state.
WordPress + ACF #
The CMS model is pleasantly boring:
projects
image
title
subtitle
description
link
<?php if ( have_rows( 'projects' ) ) : ?>
<div class="projects__grid">
<?php
$index = 0;
while ( have_rows( 'projects' ) ) :
the_row();
$index++;
$image =
get_sub_field( 'image' );
$title =
get_sub_field( 'title' );
$subtitle =
get_sub_field( 'subtitle' );
$description =
get_sub_field( 'description' );
$link =
get_sub_field( 'link' );
?>
<article
class="project-card"
data-project
data-description="<?=
esc_attr( $description );
?>"
data-link="<?=
esc_url( $link );
?>"
>
<button
type="button"
class="project-card__trigger"
data-project-open
aria-haspopup="dialog"
>
<div class="project-card__media">
<?= wp_get_attachment_image(
$image['ID'],
'large',
false,
[
'loading' => 'lazy',
'alt' => '',
]
); ?>
</div>
<div class="project-card__content">
<span class="project-card__number">
<?= esc_html(
str_pad(
$index,
2,
'0',
STR_PAD_LEFT
)
); ?>
</span>
<div>
<h3>
<?= esc_html(
$title
); ?>
</h3>
<?php if (
$subtitle
) : ?>
<p>
<?= esc_html(
$subtitle
); ?>
</p>
<?php endif; ?>
</div>
<span
class="project-card__arrow"
aria-hidden="true"
>
↗
</span>
</div>
</button>
</article>
<?php endwhile; ?>
</div>
<?php endif; ?>
Then this demo object:
const projectContent = {};
can disappear.
Read directly from:
card.dataset.description
card.dataset.link
and the entire component becomes CMS-driven.
How I Would Push It Further #
The next experiment would be multiple shared elements.
Right now we have:
panel → panel
image → image
We could add:
title → title
number → number
Then opening the project would behave more like a layout physically reorganizing itself:
CARD
│
├── image ─────→ modal hero
├── title ─────→ large modal title
└── number ────→ modal header
Everything else could simply crossfade.
I would not begin with five shared elements.
One panel transition plus one image transition already creates a surprisingly expensive-looking interaction with very little animation-specific JavaScript.
That restraint matters.
The API is useful here precisely because I am not using a new browser feature just to demonstrate that I know it exists.
It removes work.
And at the same time, the interface becomes easier to understand.
Frequently Asked Questions #
What is the View Transitions API? #
The View Transitions API lets a browser animate between different visual states of a document. In a same-document transition, JavaScript changes the DOM while the browser coordinates snapshots of the old and new states.
Can View Transitions animate two different DOM elements? #
That is one of the useful patterns. Corresponding elements can be associated through the same view-transition-name, allowing the old visual state to transition toward the new one.
See Chrome’s guide:
https://developer.chrome.com/docs/web-platform/view-transitions/same-document
Do I still need GSAP Flip? #
Not necessarily for straightforward shared-state transitions.
GSAP Flip remains much broader and can be the right tool for complicated animation systems. But a card-to-dialog transformation can now be built with the browser platform itself.
What happens in browsers without startViewTransition()? #
The DOM change should still happen.
This implementation checks:
if (!document.startViewTransition) {
update();
}
so unsupported browsers get a normal dialog without the morph animation.
Why use a native dialog? #
Because modal behavior should not depend on the animation layer.
The <dialog> element gives the component a native modal primitive while the View Transition remains progressive enhancement.
Does the animation respect reduced-motion preferences? #
Yes.
The JavaScript avoids starting a View Transition when prefers-reduced-motion: reduce matches, and CSS removes the component’s additional motion.
Why does the sample have so much JavaScript? #
Because it includes defensive behavior intended for testing something closer to production: interrupted transitions, cleanup, focus restoration, reduced motion and native dialog events.
For a pure API experiment, much of it can be removed.
Where is a morphing modal useful? #
Strong candidates include portfolios, e-commerce quick views, property listings, team profiles, job listings, service details and any interface where an existing card expands into additional information.
Conclusion #
The interesting thing about View Transitions is not that browsers can animate one rectangle into another.
We have been able to fake that for years.
The interesting part is how little custom geometry code we now need to maintain the identity of an interface object across states.
A project card does not have to disappear so that a modal can appear.
It can become the modal.
That small difference changes how the interaction reads:
old model:
card → gone
modal → appears
new model:
card
↓
same project
↓
more information
And this is where I think modern browser APIs become commercially interesting.
Not when they help us add more animation.
When they let us express a better interaction model with less animation infrastructure.