Reputation: 1483
I'm experimenting a smooth tab switch animation using the View Transition API.
In my CodePen example (https://codepen.io/moorthy-g/pen/ByaQZBv), switching from 'Introduction' to 'Technology' is smooth because the tab widths are almost equal. However, switching to 'Storytelling in a Small Village' causes the active element to scale. How can I prevent this scaling behavior and maintain a smooth slide animation for all tab width transitions?
The issue occurs only if the tab widths are different
<div class="content">
<div class="tabs">
<div class="tab active" data-tab="1">Introduction</div>
<div class="tab" data-tab="2">Storytelling in a Small Village</div>
<div class="tab" data-tab="3">Technology</div>
</div>
<div class="tab-content">
<div data-tab-content="1" style="display: block">
<p>Lorem ipsum dolor sit amet.</p>
</div>
<div data-tab-content="2" style="display: none">
<p>In a faraway land, there was a small village.</p>
</div>
<div data-tab-content="3" style="display: none">
<p>Technology has transformed the world.</p>
</div>
</div>
</div>
.tabs {
position: relative;
}
.tab {
padding: 10px;
background-color: #f0f0f0;
cursor: pointer;
position: relative;
z-index: 0;
overflow: hidden;
display: inline-block;
}
.tab:hover {
background-color: #fff;
}
.tab.active {
color: #fff;
}
.tab.active::after {
view-transition-name: tab;
content: "";
position: absolute;
left: 0;
bottom: 0;
width: 100%;
height: 100%;
background-color: #007bff;
z-index: -1;
}
.tab-content {
padding: 20px;
}
::view-transition-group(tab) {
animation-duration: 0.5s;
}
const tabs = document.querySelectorAll(".tab");
const tabContents = document.querySelectorAll(".tab-content > div");
let currentTab = 0;
tabs.forEach((tab, index) => {
tab.addEventListener("click", () => {
currentTab = index;
document.startViewTransition(() => {
tabs.forEach((t, i) => {
if (i === currentTab) {
t.classList.add("active");
} else {
t.classList.remove("active");
}
});
});
tabContents.forEach((tc, i) => {
if (i === currentTab) {
tc.style.display = "block";
} else {
tc.style.display = "none";
}
});
});
});
// Set the first tab as default
document.querySelector(".tab").click();
Upvotes: 4
Views: 167
Reputation: 44088
z-index
Example A and Example B have the same solution. Example A is the fixed OP code and Example B is the code I wrote before fixing Example B. The most significant difference between them is that the tabs in Example B are in a CSS grid layout which keeps each tab the same width and height. It just looks better with evenly sized tabs, the functionality is the same.
My solution starts off with the same solution as the accepted answer. The old and new views needed a fixed height (ref. View transitions: Handling aspect ratio changes - JakeArchibald.com):
::view-transition-old(tab),
::view-transition-new(tab) {
height: 100%;
}
The second part of this solution addresses a behavior wherein the background of the pseudo-element hides the text during a transition (see gif at accepted answer). The reason why that's happening is because all pseudo-elements with sub-zero z-index
(eg. z-index: -1
) need their closest relative
element (eg. .tab
) have their own view-transition-name
. The absolute position
ed pseudo-elements (eg. .tab::before
) look as if they are in the same stacking order (they're not), anyways if we use pseudo-elements and having the text of the tabs visible 100% of the time is important, add this CSS as well:
button {
view-transition-class: nav;
}
::view-transition-group(.nav) {
z-index: 1;
}
Each relative
element needs their own view-transition-name
so each one can be assigned z-index: 1
(the text is at z-index: 0
so it needs to be a tad higher). That can get pretty cumbersome if you have a lot of them. Fortunately, the CSS property view-transition-class
allows us to assign an arbitrary className
to a group of elements which in turn can share the same ::view-transition-group()
. (ref. Replace your JavaScript Animation Library with View).
const tabs = document.querySelectorAll(".tab");
const tabContents = document.querySelectorAll(".tab-content > div");
let currentTab = 0;
tabs.forEach((tab, index) => {
tab.addEventListener("click", () => {
currentTab = index;
document.startViewTransition(() => {
tabs.forEach((t, i) => {
if (i === currentTab) {
t.classList.add("active");
} else {
t.classList.remove("active");
}
});
});
tabContents.forEach((tc, i) => {
if (i === currentTab) {
tc.style.display = "block";
} else {
tc.style.display = "none";
}
});
});
});
body {
height: 100vh;
font-family: Arial, sans-serif;
background-color: #f0f0f0;
}
.content {
width: 100%;
max-width: 600px;
margin: 2rem auto;
padding: 20px;
border-radius: 10px;
text-align: center;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
background-color: #fff;
}
.tabs {
display: flex;
justify-content: center;
gap: 10px;
}
.tab {
position: relative;
z-index: 0;
display: flex;
place-items: center;
padding: 10px;
background: #f0f0f0;
cursor: pointer;
}
.tab.active {
color: #fff;
background-color: #007bff;
view-transition-name: tab;
}
.tab.active::before {
content: "";
position: absolute;
z-index: -1;
height: 100%;
background-color: #007bff;
}
.tab-content {
padding: 20px;
}
::view-transition-group(tab) {
animation-duration: 0.4s;
}
::view-transition-old(tab),
::view-transition-new(tab) {
height: 100%;
}
button {
view-transition-class: nav;
}
::view-transition-group(.nav) {
z-index: 1;
}
/*
|| Remove the comment if your browser doesn't
|| support `view-transition-class` yet
|| ๐ https://caniuse.com/mdn-css_properties_view-transition-class
/
::view-transition-group(nav1) {
z-index: 1;
}
::view-transition-group(nav2) {
z-index: 1;
}
::view-transition-group(nav3) {
z-index: 1;
}
*/
<div class="content">
<div class="tabs">
<div class="tab active" data-tab="1">Introduction</div>
<div class="tab" data-tab="2">Storytelling in a Small Village</div>
<div class="tab" data-tab="3">Technology</div>
</div>
<div class="tab-content">
<div data-tab-content="1" style="display: block">
<p>Lorem ipsum dolor sit amet.</p>
</div>
<div data-tab-content="2" style="display: none">
<p>In a faraway land, there was a small village.</p>
</div>
<div data-tab-content="3" style="display: none">
<p>Technology has transformed the world.</p>
</div>
</div>
</div>
const app = document.forms.app;
const io = app.elements;
const tabs = Array.from(io.tab);
const secs = Array.from(io.sec);
const updateView = (e) => {
const clk = e.target;
const idx = tabs.indexOf(clk);
document.startViewTransition(() => {
tabs.forEach((tab, i) => {
tab.classList.remove("active");
secs[i].classList.remove("active");
});
clk.classList.add("active");
secs[idx].classList.add("active");
});
};
tabs.forEach((tab) => (tab.onclick = updateView));
*,
*::before {
margin: 0;
padding: 0;
border: 0;
box-sizing: border-box;
}
:root {
font-size: clamp(1rem, 0.625rem + 2vw, 2rem);
line-height: 1.5;
font-family: "Segoe UI";
}
html,
body {
height: 100%;
}
body {
background: #fddddf;
}
#app {
width: 80vw;
max-width: 600px;
margin: 2rem auto;
padding: 0.5rem;
border-radius: 8px;
box-shadow:
rgba(0, 0, 0, 0.25) 0px 54px 55px,
rgba(0, 0, 0, 0.12) 0px -12px 30px,
rgba(0, 0, 0, 0.12) 0px 4px 6px,
rgba(0, 0, 0, 0.17) 0px 12px 13px,
rgba(0, 0, 0, 0.09) 0px -3px 5px;
}
#tabs {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
}
.tab {
position: relative;
padding: 0.25rem 0.50rem 0.50rem;
font: inherit;
color: #000;
background: transparent;
cursor: pointer;
}
.tab.active {
color: #fff;
}
.tab.active::before {
content: "";
position: absolute;
inset: 0;
top: unset;
z-index: -1;
height: 100%;
border-radius: 8px;
background: #00f;
view-transition-name: tab;
}
#box {
margin-top: 1rem;
padding: 0 0.75rem 1rem;
}
.sec {
display: none;
}
.sec p {
font-size: 0.85rem;
}
.sec.active {
display: block;
view-transition-name: sec;
}
::view-transition-group(tab) {
animation-duration: 400ms;
}
::view-transition-old(tab),
::view-transition-new(tab) {
height: 100%;
}
button {
view-transition-class: nav;
}
::view-transition-group(.nav) {
z-index: 1;
}
/*
|| Remove the comment if your browser doesn't
|| support `view-transition-class` yet
|| ๐ https://caniuse.com/mdn-css_properties_view-transition-class
/
::view-transition-group(nav1) {
z-index: 1;
}
::view-transition-group(nav2) {
z-index: 1;
}
::view-transition-group(nav3) {
z-index: 1;
}
*/
<form id="app">
<fieldset id="tabs">
<button name="tab" class="nav tab active" style="view-transition-name: nav1;" type="button">
Pulp Fiction
</button>
<button name="tab" class="nav tab" style="view-transition-name: nav2;" type="button">
Once Upon a Time... in Hollywood
</button>
<button name="tab" class="nav tab" style="view-transition-name: nav3;" type="button">
Reservoir Dogs
</button>
</fieldset>
<fieldset id="box">
<object name="sec" class="sec active">
<p>The path of the righteous man is beset on all sides by the iniquities of the selfish and the tyranny of evil men. Blessed is he who, in the name of charity and good will, shepherds the weak through the valley of darkness, for he is truly his brother's keeper and the finder of lost children. And I will strike down upon thee with great vengeance and furious anger those who would attempt to poison and destroy My brothers. And you will know My name is the Lord when I lay My vengeance upon thee.</p>
</object>
<object name="sec" class="sec">
<p>When you come to the end of the line, with a buddy who is more than a brother and a little less than a wife, getting blind drunk together is really the only way to say farewell.</p>
</object>
<object name="sec" class="sec">
<p>Torture You? Thatโs A Good Idea. I Like That.</p>
</object>
</fieldset>
</form>
Upvotes: 1
Reputation: 76943
I have tried your code, implemented a test.html with this code:
<html>
<head>
<script>
function load() {
const tabs = document.querySelectorAll(".tab");
const tabContents = document.querySelectorAll(".tab-content > div");
let currentTab = 0;
tabs.forEach((tab, index) => {
tab.addEventListener("click", () => {
currentTab = index;
document.startViewTransition(() => {
tabs.forEach((t, i) => {
if (i === currentTab) {
t.classList.add("active");
} else {
t.classList.remove("active");
}
});
});
tabContents.forEach((tc, i) => {
if (i === currentTab) {
tc.style.display = "block";
} else {
tc.style.display = "none";
}
});
});
});
// Set the first tab as default
document.querySelector(".tab").click();
}
</script>
<style>
.tabs {
position: relative;
}
.tab {
padding: 10px;
background-color: #f0f0f0;
cursor: pointer;
position: relative;
z-index: 0;
overflow: hidden;
display: inline-block;
}
.tab:hover {
background-color: #fff;
}
.tab.active {
color: #fff;
}
.tab.active::after {
view-transition-name: tab;
content: "";
position: absolute;
left: 0;
bottom: 0;
width: 100%;
height: 100%;
background-color: #007bff;
z-index: -1;
}
.tab-content {
padding: 20px;
}
::view-transition-group(tab) {
animation-duration: 0.5s;
}
</style>
</head>
<body onload="load()">
<div class="content">
<div class="tabs">
<div class="tab active" data-tab="1">Introduction</div>
<div class="tab" data-tab="2">Storytelling in a Small Village</div>
<div class="tab" data-tab="3">Technology</div>
</div>
<div class="tab-content">
<div data-tab-content="1" style="display: block">
<p>Lorem ipsum dolor sit amet.</p>
</div>
<div data-tab-content="2" style="display: none">
<p>In a faraway land, there was a small village.</p>
</div>
<div data-tab-content="3" style="display: none">
<p>Technology has transformed the world.</p>
</div>
</div>
</div>
</body>
</html>
and in Chrome it works smoothly. Here's a screencast video demonstrating that it works properly in my Chrome even if the width is different: https://app.screencast.com/1VRxLwd93y0Nj?conversation=cGWcY5VzGK7l3zTU0KwaVi
Therefore this works in Chrome and it should work in your browser too. If it is not working in your browser, then make sure you have the latest version of your browser.
Upvotes: 0
Reputation: 8780
The issue is caused by the fact that when switching the .active
class, there is a brief moment when two ::after
elements exist, stacking on top of each other. The new one appears on top, while the old one is still fading out below. The solution is to ensure that at any given time, there is a maximum of one ::after
element.
I've reworked your code a bit and highlighted the key parts: switching tabs.
flex
container, which I believe is a cleaner solution compared to using inline-block
.::after
, I introduced an indicator div, which can be moved within the tabs container.
::after
, there will always be just one indicator..tab.active
becomes transparent, and the text color turns white.
const tabs = document.querySelectorAll(".tab");
const indicator = document.createElement("div");
indicator.classList.add("tab-indicator");
document.querySelector(".tabs").appendChild(indicator);
function updateIndicator(activeTab) {
const { left, width } = activeTab.getBoundingClientRect();
const parentLeft = activeTab.parentElement.getBoundingClientRect().left;
indicator.style.left = `${left - parentLeft}px`;
indicator.style.width = `${width}px`;
}
// Add click event for tabs
tabs.forEach(tab => {
tab.addEventListener("click", () => {
document.querySelector(".tab.active")?.classList.remove("active");
tab.classList.add("active");
document.startViewTransition(() => updateIndicator(tab));
});
});
// Activate first tab
const firstTab = document.querySelector(".tab.active") || document.querySelector(".tab");
updateIndicator(firstTab);
.tabs {
position: relative;
display: flex;
flex-wrap: wrap;
}
.tab {
flex: 1;
padding: 10px;
background-color: #f0f0f0;
cursor: pointer;
position: relative;
text-align: center;
transition: color 0.15s ease;
}
.tab:hover {
background-color: #ddd;
}
.tab.active {
color: #fff;
background-color: transparent;
transition: all 0.5s; /* not important */
view-transition-name: active-tab;
}
.tab-indicator {
position: absolute;
bottom: 0;
left: 0;
height: 100%;
background-color: #007bff;
z-index: -1;
view-transition-name: tab-bg;
transition: width 0.5s ease-in-out;
}
<div class="tabs">
<div class="tab active" data-tab="1">Introduction</div>
<div class="tab" data-tab="2">Storytelling in a Small Village</div>
<div class="tab" data-tab="3">Technology</div>
</div>
Implementing the example into your original code:
const tabs = document.querySelectorAll(".container > .tabs > .tab");
const tabContents = document.querySelectorAll(".container > .tab-content > *");
const indicator = document.createElement("div");
indicator.classList.add("tab-indicator");
document.querySelector(".tabs").appendChild(indicator);
function updateIndicator(activeTab) {
const { left, width } = activeTab.getBoundingClientRect();
const parentLeft = activeTab.parentElement.getBoundingClientRect().left;
indicator.style.left = `${left - parentLeft}px`;
indicator.style.width = `${width}px`;
}
// Add click event for tabs
tabs.forEach((tab, index) => {
tab.addEventListener("click", () => {
document.querySelector(".tab.active")?.classList.remove("active");
tab.classList.add("active");
document.startViewTransition(() => updateIndicator(tab));
// Hide all tab contents and display the clicked one
tabContents.forEach((tc, i) => {
if (i === index) {
tc.classList.add("active");
} else {
tc.classList.remove("active");
}
});
});
});
// Activate first tab
const firstTab = document.querySelector(".tab.active") || document.querySelector(".tab");
updateIndicator(firstTab);
tabContents[0]?.classList?.add("active");
body {
font-family: Arial, sans-serif;
background-color: #f0f0f0;
display: flex;
justify-content: center;
align-items: start;
height: 100vh;
margin: 2rem 0;
}
.container {
text-align: center;
background-color: #fff;
padding: 20px;
border-radius: 10px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
width: 100%;
max-width: 600px;
}
.tabs {
position: relative;
display: flex;
flex-wrap: wrap;
z-index: 1;
}
.tab {
flex: 1;
padding: 10px;
background-color: #f0f0f0;
cursor: pointer;
position: relative;
text-align: center;
transition: color 0.15s ease;
}
.tab:hover {
background-color: #ddd;
}
.tab.active {
color: #fff;
background-color: transparent;
transition: all 0.5s; /* not important */
view-transition-name: active-tab;
}
.tab-indicator {
position: absolute;
bottom: 0;
left: 0;
height: 100%;
background-color: #007bff;
z-index: -1;
view-transition-name: tab-bg;
transition: width 0.5s ease-in-out;
}
.tab-content {
padding: 20px;
}
.tab-content > * {
display: none;
}
.tab-content > *.active {
display: block;
}
<div class="container">
<div class="tabs">
<div class="tab active" data-tab="1">Introduction</div>
<div class="tab" data-tab="2">Storytelling in a Small Village</div>
<div class="tab" data-tab="3">Technology</div>
</div>
<div class="tab-content">
<div data-tab-content="1">
<p>Lorem ipsum dolor sit amet.</p>
</div>
<div data-tab-content="2">
<p>In a faraway land, there was a small village.</p>
</div>
<div data-tab-content="3">
<p>Technology has transformed the world.</p>
</div>
</div>
</div>
Use indicator's background-color instead of transparent:
const tabs = document.querySelectorAll(".container > .tabs > .tab");
const tabContents = document.querySelectorAll(".container > .tab-content > *");
const indicator = document.createElement("div");
indicator.classList.add("tab-indicator");
document.querySelector(".tabs").appendChild(indicator);
function updateIndicator(activeTab) {
const { left, width } = activeTab.getBoundingClientRect();
const parentLeft = activeTab.parentElement.getBoundingClientRect().left;
indicator.style.left = `${left - parentLeft}px`;
indicator.style.width = `${width}px`;
}
// Add click event for tabs
tabs.forEach((tab, index) => {
tab.addEventListener("click", () => {
document.querySelector(".tab.active")?.classList.remove("active");
tab.classList.add("active");
document.startViewTransition(() => updateIndicator(tab));
// Hide all tab contents and display the clicked one
tabContents.forEach((tc, i) => {
if (i === index) {
tc.classList.add("active");
} else {
tc.classList.remove("active");
}
});
});
});
// Activate first tab
const firstTab = document.querySelector(".tab.active") || document.querySelector(".tab");
updateIndicator(firstTab);
tabContents[0]?.classList?.add("active");
body {
font-family: Arial, sans-serif;
background-color: #f0f0f0;
display: flex;
justify-content: center;
align-items: start;
height: 100vh;
margin: 2rem 0;
}
.container {
text-align: center;
background-color: #fff;
padding: 20px;
border-radius: 10px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
width: 100%;
max-width: 600px;
}
.tabs {
position: relative;
display: flex;
flex-wrap: wrap;
z-index: 1;
}
.tab {
flex: 1;
padding: 10px;
background-color: #f0f0f0;
cursor: pointer;
position: relative;
text-align: center;
transition: color 0.15s ease;
}
.tab:hover {
background-color: #ddd;
}
.tab.active {
color: #fff;
background-color: #007bff; /* changed here */
transition: all 0.35s; /* not important */
view-transition-name: active-tab;
}
.tab-indicator {
position: absolute;
bottom: 0;
left: 0;
height: 100%;
background-color: #007bff;
z-index: -1;
view-transition-name: tab-bg;
transition: width 0.5s ease-in-out;
}
.tab-content {
padding: 20px;
}
.tab-content > * {
display: none;
}
.tab-content > *.active {
display: block;
}
<div class="container">
<div class="tabs">
<div class="tab active" data-tab="1">Introduction</div>
<div class="tab" data-tab="2">Storytelling in a Small Village</div>
<div class="tab" data-tab="3">Technology</div>
</div>
<div class="tab-content">
<div data-tab-content="1">
<p>Lorem ipsum dolor sit amet.</p>
</div>
<div data-tab-content="2">
<p>In a faraway land, there was a small village.</p>
</div>
<div data-tab-content="3">
<p>Technology has transformed the world.</p>
</div>
</div>
</div>
Upvotes: 1
Reputation: 5494
When view transitions are applied, apart from default cross-fade animation this is what happens:
height
and width
are transitioned using a smooth scaling animationposition
and transform
are transitioned using a smooth movement animationSo in your case you need to disable the height animation by setting a fixed height in both old and new views:
::view-transition-old(tab),
::view-transition-new(tab) {
height: 100%;
}
So only the width is scaled up/down. And the transition looks smooth like this:
Upvotes: 3
Reputation: 177
This issue occurs because of the ::view-transition-group(tab) effect, which applies a default scaling transition when the dimensions of the elements change. To fix this and avoid the zoom effect while still having a smooth transition, use below CSS
body {
font-family: Arial, sans-serif;
background-color: #f0f0f0;
display: flex;
justify-content: center;
align-items: start;
height: 100vh;
margin: 2rem 0;
}
.content {
text-align: center;
background-color: #fff;
padding: 20px;
border-radius: 10px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
width: 100%;
max-width: 600px;
}
.tabs {
display: flex;
justify-content: space-between;
flex-wrap: nowrap; /* Ensure all tabs stay in one row */
}
.tab {
display: flex;
flex: 1;
text-align: center;
align-items: center;
justify-content: center;
padding: 10px;
background-color: #f0f0f0;
cursor: pointer;
position: relative;
z-index: 0;
overflow: hidden;
transition: background-color 0.3sease, color 0.3sease;
}
.tab:hover {
background-color: #fff;
}
.tab.active {
color: #fff;
}
.tab.active::after {
view-transition-name: tab;
content: "";
position: absolute;
left: 0;
bottom: 0;
width: 100%;
height: 100%;
background-color: #007bff;
z-index: -1;
}
.tab-content {
padding: 20px;
}
::view-transition-group(tab-bg) {
animation-duration: 0.3s; /* Smooth transition without zoom effect */
animation-timing-function: ease-in-out;
}
Upvotes: 0