Welcome back, animation enthusiast! In the previous chapters, we laid the groundwork for understanding Scoped View Transitions, learning how to initiate them on specific elements and appreciating the magic they perform under the hood. You’ve seen the default fade-in and fade-out, which are pretty neat for a quick win.
But what if you want more? What if you envision a dramatic slide, a subtle bounce, or a playful flip for your transitioning elements? That’s exactly what we’ll tackle in this chapter! We’re going to dive deep into customizing these transitions using the power of CSS Keyframes, giving you granular control over every animated pixel within your scoped transitions. Get ready to unleash your creativity and make your web interfaces truly sing!
Core Concepts: The Canvas for Your Animations
Before we start writing code, let’s briefly revisit how View Transitions create their magic, as this understanding is crucial for applying custom animations.
The View Transition’s Snapshot Process – A Quick Recap
When you initiate a view transition (scoped or document-wide), the browser essentially takes two “snapshots”:
- The “Old” State: A picture of your UI before the DOM changes.
- The “New” State: A picture of your UI after the DOM changes.
It then creates a set of pseudo-elements that represent these snapshots and orchestrates the animation between them. For a named element, these are:
::view-transition-group(your-name): The container for the element’s transition.::view-transition-image-pair(your-name): A container holding both the old and new views.::view-transition-old(your-name): The snapshot of the element before the change.::view-transition-new(your-name): The snapshot of the element after the change.
These pseudo-elements are like regular DOM elements in many ways – and the most important way for us is that we can target them with CSS! This is where our good old friend, CSS Keyframes, steps in.
The Power of CSS Keyframes
CSS Keyframes (@keyframes) allow you to define the intermediate steps (or “keyframes”) in a CSS animation sequence. You specify styles at various points (0%, 50%, 100%, etc.) within the animation, and the browser smoothly interpolates between these states.
By default, View Transitions apply a simple fade-in and fade-out animation to the ::view-transition-old and ::view-transition-new pseudo-elements. To create custom animations, we simply override these defaults by applying our own @keyframes animations to these very same pseudo-elements.
Scoped Context: Independent Animation Orchestration
Here’s where the “scoped” part truly shines in the context of custom animations:
When you call element.startViewTransition(), the pseudo-elements created (like ::view-transition-group, ::view-transition-old, etc.) are scoped to that particular element. This means:
- Multiple Concurrent Transitions: You can have several different elements transitioning simultaneously, each with its own unique
view-transition-nameand potentially its own custom animation. - Independent Styling: Styles and
@keyframesapplied to::view-transition-old(my-card)will only affect the transition of the element namedmy-card, not any other elements on the page.
This gives you incredible flexibility to create dynamic, interactive UIs where different components animate independently and creatively.
Step-by-Step Implementation: Bringing Animations to Life
Let’s build on our previous examples. We’ll create a simple set of cards, and when you click a button within a card, we’ll toggle some content inside that specific card, animating only that card using a custom slide effect.
1. Initial Setup: HTML and Base CSS
We’ll start with a basic HTML structure containing a few cards. Each card will have a button to trigger the content toggle and a view-transition-name to identify it.
Create an index.html file:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Scoped View Transitions with Custom Keyframes</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<h1>Custom Scoped Transitions</h1>
<div class="card-container">
<div class="card" id="card-1">
<h2 class="card-title">Card One</h2>
<p class="card-content">This is the initial content for card one. Click the button to see more!</p>
<button class="toggle-btn">Toggle Info</button>
<div class="extra-info hidden">
<p>More details for Card One. This content will appear/disappear with a custom animation!</p>
<ul>
<li>Feature A</li>
<li>Feature B</li>
</ul>
</div>
</div>
<div class="card" id="card-2">
<h2 class="card-title">Card Two</h2>
<p class="card-content">Initial content for card two.</p>
<button class="toggle-btn">Toggle Info</button>
<div class="extra-info hidden">
<p>Additional information for Card Two.</p>
<ul>
<li>Benefit X</li>
<li>Benefit Y</li>
</ul>
</div>
</div>
<div class="card" id="card-3">
<h2 class="card-title">Card Three</h2>
<p class="card-content">Initial content for card three.</p>
<button class="toggle-btn">Toggle Info</button>
<div class="extra-info hidden">
<p>Extra facts for Card Three.</p>
<ul>
<li>Point 1</li>
<li>Point 2</li>
</ul>
</div>
</div>
</div>
<script src="script.js"></script>
</body>
</html>
Now, create a style.css file to give our cards some basic styling:
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: #f0f2f5;
color: #333;
margin: 0;
padding: 20px;
display: flex;
flex-direction: column;
align-items: center;
min-height: 100vh;
}
h1 {
color: #2c3e50;
margin-bottom: 40px;
}
.card-container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 30px;
max-width: 1200px;
width: 100%;
}
.card {
background-color: #ffffff;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
padding: 25px;
text-align: center;
display: flex;
flex-direction: column;
justify-content: space-between;
transition: transform 0.2s ease-in-out;
border: 1px solid #e0e0e0;
}
.card:hover {
transform: translateY(-5px);
}
.card-title {
color: #3498db;
margin-top: 0;
margin-bottom: 15px;
font-size: 1.8em;
}
.card-content {
font-size: 1em;
line-height: 1.6;
margin-bottom: 20px;
flex-grow: 1; /* Allows content to take up available space */
}
.toggle-btn {
background-color: #3498db;
color: white;
border: none;
padding: 12px 25px;
border-radius: 8px;
cursor: pointer;
font-size: 1.1em;
transition: background-color 0.2s ease;
margin-top: 20px;
}
.toggle-btn:hover {
background-color: #2980b9;
}
.extra-info {
margin-top: 20px;
padding-top: 15px;
border-top: 1px dashed #e0e0e0;
text-align: left;
font-size: 0.95em;
color: #555;
}
.extra-info p {
margin-bottom: 10px;
}
.extra-info ul {
list-style-type: disc;
margin-left: 20px;
padding: 0;
}
.extra-info li {
margin-bottom: 5px;
}
.hidden {
display: none;
}
2. Adding the Scoped View Transition Logic
Now, let’s add the JavaScript to handle the toggling of content and initiating the scoped view transition. Create a script.js file:
document.addEventListener('DOMContentLoaded', () => {
const cards = document.querySelectorAll('.card');
cards.forEach(card => {
const toggleBtn = card.querySelector('.toggle-btn');
const extraInfo = card.querySelector('.extra-info');
// IMPORTANT: Assign a unique view-transition-name to each card
// This allows independent transitions and custom styling.
// We'll use the card's ID for simplicity.
card.style.viewTransitionName = `card-transition-${card.id}`;
toggleBtn.addEventListener('click', () => {
// 1. Check for browser support
if (!document.startViewTransition) {
// Fallback for browsers that don't support View Transitions
extraInfo.classList.toggle('hidden');
console.warn('View Transitions API not supported. Falling back to direct CSS toggle.');
return;
}
// 2. Start the scoped view transition
// We call startViewTransition on the specific card element
const transition = card.startViewTransition(() => {
// 3. Update the DOM inside the callback
extraInfo.classList.toggle('hidden');
// Update button text
if (extraInfo.classList.contains('hidden')) {
toggleBtn.textContent = 'Toggle Info';
} else {
toggleBtn.textContent = 'Hide Info';
}
});
// You can also add .then() or .catch() to the transition promise
transition.finished.then(() => {
console.log(`Transition for ${card.id} finished!`);
});
});
});
});
Explanation of the JavaScript:
- We loop through each
.cardelement. card.style.viewTransitionName = \card-transition-${card.id}`;: This is **crucial**! We dynamically assign a uniqueview-transition-name` to each card. This tells the browser: “Hey, treat this element’s changes as a distinct, independently animating unit.” Without this, the transition would either affect the whole document or not work as expected for individual elements.card.startViewTransition(() => { ... });: Instead ofdocument.startViewTransition(), we callstartViewTransition()directly on thecardelement. This makes the transition scoped to that particular card.- Inside the callback, we toggle the
hiddenclass on theextra-infodiv and update the button text. This is the DOM change that the View Transition will animate.
At this point, if you open index.html in a browser that supports Scoped View Transitions (like Chrome Canary or recent Chrome versions with flags enabled, as of 2025-12-05, this feature is widely available but might still be considered “developer testing” in some contexts, ensure your browser is up-to-date!), you’ll see the default fade animation for each card independently. Nice!
3. Defining Custom CSS Keyframes
Now for the fun part: let’s define our own keyframes to override the default fade. We’ll create a “slide-up” effect for the new content appearing and a “slide-down” effect for the old content disappearing.
Add the following CSS to your style.css file:
/* --- Custom Keyframes --- */
@keyframes slide-in-from-bottom {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slide-out-to-bottom {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(20px);
}
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes fade-out {
from { opacity: 1; }
to { opacity: 0; }
}
Explanation of Keyframes:
@keyframes slide-in-from-bottom: This animation starts withopacity: 0(invisible) andtransform: translateY(20px)(20 pixels below its final position). It animates toopacity: 1andtransform: translateY(0)(its final, visible position).@keyframes slide-out-to-bottom: This is the reverse. It starts visible at its normal position and animates to invisible and 20 pixels below its normal position.@keyframes fade-inandfade-out: These are the default animations. We’re keeping them here for reference, but we’ll override them.
4. Applying Custom Keyframes to Pseudo-elements
This is where we tell the browser which animations to use for our scoped transitions. We target the specific pseudo-elements created by the View Transition API for our named cards.
Add these rules to your style.css file, after your keyframe definitions:
/* --- Custom View Transition Animations --- */
/* For elements named 'card-transition-card-1' */
::view-transition-old(card-transition-card-1) {
animation: 250ms ease-out slide-out-to-bottom forwards;
}
::view-transition-new(card-transition-card-1) {
animation: 300ms ease-in slide-in-from-bottom forwards;
}
/* For elements named 'card-transition-card-2' */
::view-transition-old(card-transition-card-2) {
animation: 300ms cubic-bezier(0.4, 0, 0.2, 1) fade-out forwards; /* Using default fade but with custom timing */
}
::view-transition-new(card-transition-card-2) {
animation: 350ms cubic-bezier(0.4, 0, 0.2, 1) fade-in forwards;
}
/* For elements named 'card-transition-card-3' */
@keyframes rotate-and-scale-out {
from {
opacity: 1;
transform: scale(1) rotate(0deg);
}
to {
opacity: 0;
transform: scale(0.8) rotate(-15deg);
}
}
@keyframes rotate-and-scale-in {
from {
opacity: 0;
transform: scale(0.8) rotate(15deg);
}
to {
opacity: 1;
transform: scale(1) rotate(0deg);
}
}
::view-transition-old(card-transition-card-3) {
animation: 400ms ease-out rotate-and-scale-out forwards;
}
::view-transition-new(card-transition-card-3) {
animation: 450ms ease-in rotate-and-scale-in forwards;
}
Explanation of Pseudo-element Styling:
::view-transition-old(card-transition-card-1): This targets the “old” snapshot of the element that was given theview-transition-nameofcard-transition-card-1.::view-transition-new(card-transition-card-1): This targets the “new” snapshot of the same element.animation: 250ms ease-out slide-out-to-bottom forwards;: This CSSanimationshorthand property applies our custom keyframe animation.250ms: The duration of the animation.ease-out: The timing function (starts fast, ends slow).slide-out-to-bottom: The name of our@keyframesrule.forwards: Thisanimation-fill-modeensures that the element retains the styles defined in the last keyframe after the animation completes. This is important to prevent a “flicker” or snap back to the initial state.
- Notice the different animations for each card!
card-1gets our customslide-in-from-bottomandslide-out-to-bottom.card-2uses the defaultfade-in/fade-outbut with slightly customanimation-durationandcubic-beziertiming. This shows you can tweak defaults too!card-3gets an even more complexrotate-and-scaleanimation. This highlights the power of customizing each card independently.
Now, refresh your index.html. When you click the “Toggle Info” button on Card One, you should see the content slide in/out. When you click Card Two, it will fade with a slightly different timing. And Card Three will rotate and scale! Each card transitions independently with its own unique animation, thanks to Scoped View Transitions and custom CSS Keyframes.
Mini-Challenge: Add a Subtle Bounce to Card Two
You’ve seen how to apply different animations. Now, let’s give Card Two a more distinct animation. Instead of just fading, make the ::view-transition-new for Card Two “bounce” slightly into place, while the ::view-transition-old still fades out.
Challenge:
Modify the CSS for card-transition-card-2 to make its ::view-transition-new element perform a subtle bounce animation when it appears. The ::view-transition-old should continue to fade out.
Hint:
- You’ll need to define a new
@keyframesrule, perhaps namedbounce-in. - Think about using
transform: scale()ortransform: translateY()with multiple keyframe percentages (e.g.,0%,70%,100%) to create the bounce effect. - Remember to apply this new keyframe to
::view-transition-new(card-transition-card-2).
What to Observe/Learn:
- How to combine different animation types (fade for old, bounce for new) within a single scoped transition.
- The flexibility of
@keyframesto create more dynamic motion.
Click for Solution (if you get stuck!)
/* --- Solution for Mini-Challenge: Bounce for Card Two --- */
/* New keyframes for bounce effect */
@keyframes bounce-in {
0% {
opacity: 0;
transform: scale(0.9);
}
70% {
opacity: 1;
transform: scale(1.05); /* Overshoot for bounce */
}
100% {
transform: scale(1);
}
}
/* Modify styling for card-transition-card-2 */
::view-transition-old(card-transition-card-2) {
animation: 250ms ease-out fade-out forwards; /* Keep fade-out */
}
::view-transition-new(card-transition-card-2) {
animation: 400ms cubic-bezier(0.175, 0.885, 0.32, 1.275) bounce-in forwards; /* Apply bounce-in */
}
Common Pitfalls & Troubleshooting
Forgetting
view-transition-name: If your custom animations aren’t working, or the entire page seems to be animating instead of just your element, double-check that you’ve assigned a uniqueview-transition-nameto the element you’re scoping the transition to. Remember, it needs to be set on the element beforestartViewTransition()is called.- Fix: Add
element.style.viewTransitionName = 'your-unique-name';in your JavaScript or set it directly in HTML/CSS if static.
- Fix: Add
Incorrect Pseudo-element Targeting: You might have defined beautiful keyframes, but they’re not being applied. Ensure you’re targeting the correct pseudo-elements (
::view-transition-old(name)and::view-transition-new(name)) and that thenamein the parentheses exactly matches theview-transition-nameyou’ve assigned.- Fix: Verify the CSS selectors match the JavaScript-assigned
view-transition-name.
- Fix: Verify the CSS selectors match the JavaScript-assigned
animation-fill-modeIssues (forwards): Sometimes, after your custom animation finishes, the element might “snap” back to its original state or flicker. This often happens if the final state of your keyframes isn’t correctly maintained. Theforwardsvalue foranimation-fill-modeensures the element retains the styles from the last keyframe.- Fix: Always include
forwardsin youranimationshorthand (e.g.,animation: 300ms ease-in my-keyframes forwards;) unless you specifically want it to reset.
- Fix: Always include
Performance Overheads: While View Transitions are highly optimized by the browser, overly complex animations, especially on many elements simultaneously, can still cause performance issues (jank).
- Tip: Stick to animating
opacityandtransformproperties as they are GPU-accelerated. Avoid animating properties likewidth,height,left,topwhere possible, as they can trigger costly layout recalculations. Test on various devices!
- Tip: Stick to animating
Browser Support: While View Transitions are becoming widely supported, Scoped View Transitions (calling
startViewTransitionon an element) are a newer extension. Always include a fallback for browsers that might not yet support it.- Fix: Use the
if (!document.startViewTransition)check, as shown in ourscript.js, to provide a graceful degradation.
- Fix: Use the
Summary
Phew, that was a deep dive! You’ve now mastered the art of customizing your Scoped View Transitions with CSS Keyframes. Here’s a quick recap of the key takeaways:
- Understanding the Canvas: View Transitions create special pseudo-elements (
::view-transition-old,::view-transition-new, etc.) that represent the “before” and “after” states of your UI. view-transition-nameis King: To enable custom animations on specific elements, you must assign a uniqueview-transition-nameto them. This is how the browser knows which pseudo-elements belong to which transitioning element.- CSS Keyframes for Control: You can override the default fade animations by defining your own
@keyframesand applying them to the appropriate::view-transition-old(name)and::view-transition-new(name)pseudo-elements. - Scoped Power: In a scoped context, each named element can have its own independent transition and animation, allowing for complex and engaging UI interactions.
- Animation Properties: Use
animation-duration,animation-timing-function,animation-delay, and crucially,animation-fill-mode: forwardsto fine-tune your animations. - Best Practices: Prioritize
opacityandtransformfor performance, and always provide fallbacks for browser compatibility.
You’re no longer limited to simple fades; your web interfaces can now truly come alive with custom, delightful animations!
In the next chapter, we’ll explore even more advanced techniques, diving into orchestrating multiple, complex transitions and handling user interactions with greater finesse. Get ready to build even more dynamic experiences!