Tailwind CSS 63 🎨 Complex CSS View Transitions API Styling with Tailwind
The View Transitions API is a browser feature that captures the old and new states of a page and animates between them. It produces smooth transitions between routes, between the themes, and between the states of a component, with the browser handling the snapshots and the crossfade. Tailwind does not ship the built-in utilities for the API, so the styling is done through arbitrary values, pseudo-elements, and the community plugins. This chapter covers the API, the Tailwind integration, and the patterns that make the transitions feel designed rather than accidental.
Key point: The view-transition-name property is what tells the browser which elements to animate. Two elements on the old and new pages with the same name are captured and animated between. An element without a name is part of the root snapshot and fades with the rest of the page.
Why the View Transitions API matters
The flash problem. A theme toggle that switches from light to dark produces a sudden flash. A route navigation that replaces the content produces a hard cut. The user perceives the change as a glitch rather than a transition. The View Transitions API turns the change into a smooth animation.
The shared-element problem. A list of cards and a detail page. The user clicks a card, and the detail page opens. Without the API, the card disappears and the detail appears. With the API and a shared view-transition-name, the card morphs into the detail page, and the user perceives the continuity.
The JavaScript problem. Before the API, the smooth transitions required a JavaScript animation library, a layout measurement, and a manual crossfade. The API is native. The browser captures the snapshots, and the CSS controls the animation.
The cross-document problem. The API works for the same-document transitions, such as a theme toggle or a tab switch, and for the cross-document transitions, such as a page navigation. The @view-transition { navigation: auto; } rule enables the cross-document transitions.
The pseudo-element problem. The API produces a tree of the pseudo-elements: ::view-transition, ::view-transition-group(), ::view-transition-image-pair(), ::view-transition-old(), and ::view-transition-new(). The developer styles the old and the new to control the outgoing and the incoming snapshots.
The reduced-motion problem. The animations can trigger the motion sickness. The prefers-reduced-motion media query must disable the transitions for the users who opt out.
The Tailwind problem. Tailwind does not have the built-in view-transition-* utilities. The arbitrary values, the custom CSS, and the community plugins are the mechanisms. The community plugin tailwindcss-view-transitions provides the vt-name-* utilities .
a. The View Transitions API
The API has two layers: the JavaScript that starts the transition, and the CSS that styles it.
The same-document transition
The document.startViewTransition() function takes a callback that updates the DOM. The browser captures the old state, runs the callback, captures the new state, and animates between them.
function updateTheme() {
if (!document.startViewTransition) {
applyTheme();
return;
}
document.startViewTransition(() => {
applyTheme();
});
}
The callback is synchronous. The DOM change happens inside the callback, and the browser handles the capture and the animation .
The cross-document transition
The @view-transition { navigation: auto; } rule enables the transitions between the same-origin page navigations. The browser captures the old page and the new page, and the CSS controls the animation .
@view-transition {
navigation: auto;
}
The pseudo-elements
The API creates the pseudo-element tree. The ::view-transition-old(root) is the outgoing snapshot, and the ::view-transition-new(root) is the incoming snapshot .
::view-transition-old(root) {
animation: fade-out 0.3s ease-out;
}
::view-transition-new(root) {
animation: fade-in 0.3s ease-in;
}
The old and the new are the ::view-transition-old() and the ::view-transition-new() pseudo-elements. The root is the default name for the whole page.
The named elements
The view-transition-name property assigns a name to an element. The browser captures the named element separately, and the two elements with the same name on the old and new pages are animated between .
.hero-image {
view-transition-name: hero;
}
The named element is lifted out of the root snapshot and animated on its own. The rest of the page fades with the root.
The name uniqueness
The view-transition-name must be unique within a document. Two elements with the same name on the same page produce an error and no transition. The names must match on the old and new pages for the shared-element transition .
The API table
| Layer | Mechanism |
|---|---|
| JavaScript | document.startViewTransition() |
| Cross-document | @view-transition { navigation: auto; } |
| Pseudo-element | ::view-transition-old(), ::view-transition-new() |
| Name | view-transition-name |
| Reduced motion | prefers-reduced-motion |
b. The Tailwind integration
The Tailwind integration uses the arbitrary values, the pseudo-element selectors, and the community plugin.
The arbitrary value
The view-transition-name is set with the arbitrary value syntax.
<img class="[view-transition-name:hero]" src="hero.jpg" alt="" />
The class produces view-transition-name: hero. The element participates in the transition with the name hero .
The dynamic name
The name can be dynamic with the CSS custom property.
<div style="--tag: h-{slug}" class="[view-transition-name:var(--tag)]">
</div>
The view-transition-name is the value of the --tag property. The pattern is used for the list of cards, where each card has a unique name .
The vt-name-* plugin
The community plugin tailwindcss-view-transitions provides the vt-name-* utilities.
<div class="vt-name-[main-header]">
</div>
The class produces view-transition-name: main-header. The plugin also provides the vt-name-none to disable the transition for the element .
The plugin installation
// tailwind.config.js
module.exports = {
plugins: [
require("@ekafyi/tailwindcss-view-transitions"),
],
};
The plugin is the development dependency, and the vt-name-* utilities are the API .
The plugin configuration
The plugin accepts the disableAllReduceMotion and the styles options.
require("@ekafyi/tailwindcss-view-transitions")({
disableAllReduceMotion: true,
styles: {
root: {
old: { animationDuration: "1s" },
new: { animationDuration: "3s" },
},
},
});
The disableAllReduceMotion disables the transitions when the user prefers reduced motion. The styles defines the CSS for the pseudo-elements from the configuration .
The pseudo-element styling
The pseudo-elements are styled in the global CSS, not through the Tailwind utilities.
@layer base {
::view-transition-old(root) {
animation: fade-out 0.3s ease-out;
}
::view-transition-new(root) {
animation: fade-in 0.3s ease-in;
}
}
The @layer base is the Tailwind layer for the global styles. The pseudo-elements are not the utilities, so they are written in the CSS .
The dynamic transition name
The view-transition-name can be set with the inline style, and the name is the unique value.
<article style="--vt-name: post-42" class="[view-transition-name:var(--vt-name)]">
</article>
The pattern is used for the shared-element transition between the list and the detail page .
The Tailwind integration table
| Approach | Syntax |
|---|---|
| Arbitrary value | [view-transition-name:hero] |
| Custom property | [view-transition-name:var(--name)] |
| Plugin utility | vt-name-[hero] |
| Plugin disable | vt-name-none |
| Pseudo-element | ::view-transition-old(root) |
c. The common patterns
The patterns below are the ones that appear most often in the real applications.
The theme toggle
The theme toggle is the canonical same-document transition. The document.startViewTransition() wraps the class change on the <html> element.
function ThemeToggle() {
const [theme, setTheme] = useState('light');
function toggle(event) {
const newTheme = theme === 'light' ? 'dark' : 'light';
if (!document.startViewTransition) {
setTheme(newTheme);
return;
}
document.startViewTransition(() => {
setTheme(newTheme);
});
}
return <button onClick={toggle}>{theme}</button>;
}
The browser captures the old and new states and crossfades between them .
The circular reveal
The circular reveal uses the clip-path and the Web Animations API. The startViewTransition returns a .ready promise, and the animation runs on the ::view-transition-new(root).
const x = event.clientX;
const y = event.clientY;
const endRadius = Math.hypot(
Math.max(x, window.innerWidth - x),
Math.max(y, window.innerHeight - y)
);
const transition = document.startViewTransition(() => {
setTheme(newTheme);
});
transition.ready.then(() => {
document.documentElement.animate(
{ clipPath: [`circle(0px at ${x}px ${y}px)`, `circle(${endRadius}px at ${x}px ${y}px)`] },
{ duration: 400, easing: 'ease-in', pseudoElement: '::view-transition-new(root)' }
);
});
The pseudoElement option is the ::view-transition-new(root), and the animation is the clip-path expansion from the click point .
The shared-element navigation
The shared-element transition between the list and the detail uses the view-transition-name on the card and the detail.
<!-- List page -->
<article style="--vt-name: post-42" class="[view-transition-name:var(--vt-name)]">
<img src="thumb.jpg" />
</article>
<!-- Detail page -->
<article style="--vt-name: post-42" class="[view-transition-name:var(--vt-name)]">
<img src="hero.jpg" />
</article>
The browser captures the card on the list page and the detail on the detail page, and the shared name morphs between them .
The cross-document navigation
The cross-document transition uses the @view-transition rule and the consistent names.
@view-transition {
navigation: auto;
}
::view-transition-old(root) {
animation: fade-out 0.2s ease-out;
}
::view-transition-new(root) {
animation: fade-in 0.2s ease-in;
}
The rule enables the transitions between the same-origin page navigations. The root animation is the page fade .
The reduced-motion handling
The prefers-reduced-motion media query disables the transitions.
@media (prefers-reduced-motion: reduce) {
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
}
}
The query is the accessibility requirement. The animation is disabled for the users who opt out .
The view transition name on the pseudo-element
The ::view-transition-group() pseudo-element can be targeted for the specific name.
::view-transition-group(hero) {
animation-duration: 0.5s;
}
The group is the container for the old and the new images. The duration on the group controls the whole transition .
The pattern table
| Pattern | Mechanism |
|---|---|
| Theme toggle | startViewTransition() + class change |
| Circular reveal | clip-path + Web Animations API |
| Shared element | view-transition-name on both pages |
| Cross-document | @view-transition { navigation: auto; } |
| Reduced motion | prefers-reduced-motion |
Complete Example Session
<!-- ============================================
PART 1: THE THEME TOGGLE
============================================ -->
<button id="theme-toggle">Toggle theme</button>
<script>
document.getElementById('theme-toggle').addEventListener('click', () => {
if (!document.startViewTransition) {
document.documentElement.classList.toggle('dark');
return;
}
document.startViewTransition(() => {
document.documentElement.classList.toggle('dark');
});
});
</script>
<!-- ============================================
PART 2: THE PSEUDO-ELEMENT STYLING
============================================ -->
<style>
::view-transition-old(root) {
animation: fade-out 0.3s ease-out;
}
::view-transition-new(root) {
animation: fade-in 0.3s ease-in;
}
@keyframes fade-out {
to { opacity: 0; }
}
@keyframes fade-in {
from { opacity: 0; }
}
</style>
<!-- ============================================
PART 3: THE CIRCULAR REVEAL
============================================ -->
<script>
document.getElementById('theme-toggle').addEventListener('click', (event) => {
const x = event.clientX;
const y = event.clientY;
const endRadius = Math.hypot(
Math.max(x, window.innerWidth - x),
Math.max(y, window.innerHeight - y)
);
const transition = document.startViewTransition(() => {
document.documentElement.classList.toggle('dark');
});
transition.ready.then(() => {
document.documentElement.animate(
{
clipPath: [
`circle(0px at ${x}px ${y}px)`,
`circle(${endRadius}px at ${x}px ${y}px)`,
],
},
{
duration: 400,
easing: 'ease-in',
pseudoElement: '::view-transition-new(root)',
}
);
});
});
</script>
<!-- ============================================
PART 4: THE SHARED ELEMENT
============================================ -->
<!-- List page -->
<a href="/post/42" style="--vt-name: post-42" class="[view-transition-name:var(--vt-name)]">
<img src="thumb.jpg" alt="" />
<h2>Post title</h2>
</a>
<!-- Detail page -->
<article style="--vt-name: post-42" class="[view-transition-name:var(--vt-name)]">
<img src="hero.jpg" alt="" />
<h1>Post title</h1>
</article>
<!-- ============================================
PART 5: THE CROSS-DOCUMENT TRANSITION
============================================ -->
<style>
@view-transition {
navigation: auto;
}
::view-transition-old(root) {
animation: fade-out 0.2s ease-out;
}
::view-transition-new(root) {
animation: fade-in 0.2s ease-in;
}
@media (prefers-reduced-motion: reduce) {
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
}
}
</style>
<!-- ============================================
PART 6: THE TAILWIND ARBITRARY VALUE
============================================ -->
<div class="[view-transition-name:hero]">
Hero content
</div>
<div class="[view-transition-name:none]">
This element does not participate.
</div>
<!-- ============================================
PART 7: THE VT-NAME PLUGIN
============================================ -->
<div class="vt-name-[main-header]">
Header
</div>
<div class="vt-name-[main-header] motion-reduce:vt-name-none">
Header with reduced motion
</div>
<!-- ============================================
PART 8: THE DYNAMIC NAME
============================================ -->
<article style="--vt-name: post-42" class="[view-transition-name:var(--vt-name)]">
</article>
<!-- ============================================
PART 9: THE PSEUDO-ELEMENT ANIMATION
============================================ -->
<style>
::view-transition-old(hero) {
animation: slide-out 0.3s ease-out;
}
::view-transition-new(hero) {
animation: slide-in 0.3s ease-in;
}
::view-transition-group(hero) {
animation-duration: 0.5s;
}
</style>
<!-- ============================================
PART 10: THE COMPLETE PATTERN
============================================ -->
<!--
1. Use document.startViewTransition() for the same-document transitions.
2. Use @view-transition { navigation: auto; } for the cross-document.
3. Use view-transition-name to name the shared elements.
4. Use the arbitrary value [view-transition-name:hero] in Tailwind.
5. Use the vt-name-[hero] plugin utility.
6. Use the CSS custom property for the dynamic names.
7. Style the ::view-transition-old() and ::view-transition-new().
8. Use the clip-path and the Web Animations API for the custom effects.
9. Use prefers-reduced-motion to disable the animations.
10. Use the unique names per document.
-->
The ten parts showed the theme toggle, the pseudo-element styling, the circular reveal, the shared element, the cross-document transition, the Tailwind arbitrary value, the plugin, the dynamic name, the pseudo-element animation, and the complete pattern.
Quick Reference
The API
| Mechanism | Syntax |
|---|---|
| Same-document | document.startViewTransition(() => { ... }) |
| Cross-document | @view-transition { navigation: auto; } |
| Name | view-transition-name: hero; |
| Old snapshot | ::view-transition-old(hero) |
| New snapshot | ::view-transition-new(hero) |
| Group | ::view-transition-group(hero) |
The Tailwind Integration
| Approach | Syntax |
|---|---|
| Arbitrary value | [view-transition-name:hero] |
| Custom property | [view-transition-name:var(--name)] |
| Plugin utility | vt-name-[hero] |
| Plugin disable | vt-name-none |
The Plugin Options
| Option | Type | Purpose |
|---|---|---|
disableAllReduceMotion | Boolean | Disable when reduced motion |
styles | Object | Define the pseudo-element CSS |
The Pseudo-Element Properties
| Property | Effect |
|---|---|
animation | The transition animation |
animation-duration | The duration |
mix-blend-mode | The blend with the page |
width, height | The dimensions |
The Common Patterns
| Pattern | Mechanism |
|---|---|
| Theme toggle | startViewTransition() |
| Circular reveal | clip-path + Web Animations |
| Shared element | view-transition-name |
| Page navigation | @view-transition |
| Reduced motion | prefers-reduced-motion |
Best Practices
✅ Do This:
<!-- Use the arbitrary value for the view-transition-name. -->
<div class="[view-transition-name:hero]"> <!-- ✅ -->
<!-- Use the CSS custom property for the dynamic names. -->
<article style="--vt-name: post-42" class="[view-transition-name:var(--vt-name)]"> <!-- ✅ -->
<!-- Use the vt-name-* plugin for the cleaner syntax. -->
<div class="vt-name-[hero]"> <!-- ✅ -->
<!-- Style the ::view-transition-old() and ::view-transition-new(). -->
<style>
::view-transition-old(root) { animation: fade-out 0.3s; } <!-- ✅ -->
</style>
<!-- Use the prefers-reduced-motion. -->
@media (prefers-reduced-motion: reduce) { ::view-transition-old(root), ::view-transition-new(root) { animation: none; } } <!-- ✅ -->
<!-- Use the unique names per document. -->
<div class="[view-transition-name:hero-1]"> <!-- ✅ -->
❌ Don’t Do This:
<!-- Don't use the same name twice on the same page. -->
<div class="[view-transition-name:hero]"></div>
<div class="[view-transition-name:hero]"></div> <!-- ❌ -->
<!-- Don't name the root element. -->
<div class="[view-transition-name:root]"> <!-- ❌ Reserved -->
<!-- Don't forget the reduced-motion handling. -->
<!-- The accessibility failure. --> <!-- ❌ -->
<!-- Don't use the view-transition-name on the overlay elements. -->
<div class="fixed inset-0 [view-transition-name:overlay]"> <!-- ⚠️ Can clip -->
<!-- Don't forget the cross-document rule. -->
<!-- Without @view-transition, the navigation does not animate. --> <!-- ❌ -->
<!-- Don't use a long duration for the navigation. -->
::view-transition-old(root) { animation-duration: 2s; } <!-- ❌ The user waits -->
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| No transition | The API is not supported | Check document.startViewTransition |
| No shared-element | The names do not match | Use the same name on both pages |
| Double name error | Two elements with the same name | Use the unique names |
| No cross-document | The rule is missing | Add @view-transition |
| Animation clashes | The default fade + the custom | Set animation: none on the default |
| Motion sickness | No reduced-motion | Add the media query |
| Overlay clipped | The name on the fixed element | Do not name the overlays |
Real-World Examples
1. The Theme Toggle
document.startViewTransition(() => {
document.documentElement.classList.toggle('dark');
});
2. The Pseudo-Element
::view-transition-old(root) { animation: fade-out 0.3s ease-out; }
::view-transition-new(root) { animation: fade-in 0.3s ease-in; }
3. The Shared Element
<article style="--vt-name: post-42" class="[view-transition-name:var(--vt-name)]">
4. The Cross-Document
@view-transition { navigation: auto; }
5. The Tailwind Arbitrary
<div class="[view-transition-name:hero]">
6. The Plugin
<div class="vt-name-[hero]">
7. The Circular Reveal
document.documentElement.animate(
{ clipPath: [`circle(0px at ${x}px ${y}px)`, `circle(${endRadius}px at ${x}px ${y}px)`] },
{ duration: 400, pseudoElement: '::view-transition-new(root)' }
);
8. The Reduced Motion
@media (prefers-reduced-motion: reduce) { ::view-transition-old(root), ::view-transition-new(root) { animation: none; } }
9. The Pseudo-Element Group
::view-transition-group(hero) { animation-duration: 0.5s; }
10. The Complete Pattern
const transition = document.startViewTransition(() => update());
transition.ready.then(() => { /* custom animation */ });
Visual
The View Transition Tree
┌────────────────────────────────────────────────────────────┐
│ THE VIEW TRANSITION TREE │
│ │
│ ::view-transition │
│ ├── ::view-transition-group(root) │
│ │ └── ::view-transition-image-pair(root) │
│ │ ├── ::view-transition-old(root) │
│ │ └── ::view-transition-new(root) │
│ │ │
│ └── ::view-transition-group(hero) │
│ └── ::view-transition-image-pair(hero) │
│ ├── ::view-transition-old(hero) │
│ └── ::view-transition-new(hero) │
│ │
│ The old is the outgoing snapshot, the new is the incoming.│
│ The group is the container for the pair. │
└────────────────────────────────────────────────────────────┘
The Theme Toggle
┌────────────────────────────────────────────────────────────┐
│ THE THEME TOGGLE │
│ │
│ Click │
│ → document.startViewTransition(() => { │
│ document.documentElement.classList.toggle('dark'); │
│ }) │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ The browser captures the old state (light). │ │
│ │ The callback runs, the class toggles. │ │
│ │ The browser captures the new state (dark). │ │
│ │ The ::view-transition-old(root) fades out. │ │
│ │ The ::view-transition-new(root) fades in. │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ The result is a smooth crossfade, not a flash. │
└────────────────────────────────────────────────────────────┘
The Shared Element
┌────────────────────────────────────────────────────────────┐
│ THE SHARED ELEMENT │
│ │
│ LIST PAGE DETAIL PAGE │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ Card │ │ Hero │ │
│ │ [hero] │──navigate──▶│ [hero] │ │
│ │ view-trans- │ │ view-trans- │ │
│ │ ition-name │ │ ition-name │ │
│ └──────────────┘ └──────────────┘ │
│ │
│ The browser captures both, matches the names, and morphs │
│ the card into the hero. │
└────────────────────────────────────────────────────────────┘
The Reduced Motion
┌────────────────────────────────────────────────────────────┐
│ THE REDUCED MOTION │
│ │
│ @media (prefers-reduced-motion: reduce) { │
│ ::view-transition-old(root), │
│ ::view-transition-new(root) { │
│ animation: none; │
│ } │
│ } │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ The user prefers reduced motion. │ │
│ │ The transition is disabled. │ │
│ │ The change happens instantly. │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ The accessibility requirement. │
└────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
startViewTransition() | The same-document transition |
@view-transition | The cross-document transition |
view-transition-name | The name of the captured element |
::view-transition-old() | The outgoing snapshot |
::view-transition-new() | The incoming snapshot |
::view-transition-group() | The container for the pair |
vt-name-[name] | The plugin utility |
[view-transition-name:name] | The Tailwind arbitrary value |
prefers-reduced-motion | The accessibility |
Key takeaways:
- The View Transitions API captures the old and new states and animates between them. The
document.startViewTransition()function is the same-document transition, and the@view-transition { navigation: auto; }rule is the cross-document transition. - The
view-transition-nameproperty is the shared-element mechanism. Two elements on the old and new pages with the same name are captured and animated between. The name must be unique per document and match across the pages. - Tailwind does not have the built-in utilities. The arbitrary values (
[view-transition-name:hero]) and the community plugin (vt-name-[hero]) are the mechanisms. The pseudo-elements are styled in the global CSS. - The
::view-transition-old()and::view-transition-new()are the snapshots. The old is the outgoing, and the new is the incoming. The::view-transition-group()is the container for the pair. - The circular reveal uses the
clip-pathand the Web Animations API. Thetransition.readypromise is the hook, and thepseudoElement: '::view-transition-new(root)'is the target. - The reduced-motion handling is the accessibility. The
prefers-reduced-motionmedia query disables the animations for the users who opt out. - The names must be unique per document. Two elements with the same name produce an error and no transition. The overlays should not be named, because the named elements can clip their contents.
- The cross-document transition requires the rule and the consistent names. The
@view-transitionrule enables the transition, and theview-transition-nameon the same elements across the pages is the shared-element mechanism.
Remember: The View Transitions API is the browser-native way to animate between the states and the pages. The startViewTransition function and the @view-transition rule are the entry points, and the view-transition-name is the shared-element mechanism. Tailwind integrates through the arbitrary values, the community plugin, and the pseudo-element CSS. The circular reveal, the theme toggle, and the page navigation are the patterns. The result is a UI where the changes feel continuous, the shared elements morph, and the reduced-motion users are respected.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!