Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

View Transition API: Tab Animation Scaling Issue

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();
like image 331
Moorthy G Avatar asked Oct 26 '25 05:10

Moorthy G


2 Answers

When view transitions are applied, apart from default cross-fade animation this is what happens:

  • height and width are transitioned using a smooth scaling animation
  • position and transform are transitioned using a smooth movement animation

So 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:

View transition - disabled height animation

like image 129
Raghavendra N Avatar answered Oct 27 '25 17:10

Raghavendra N


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.

  • I placed it in a flex container, which I believe is a cleaner solution compared to using inline-block.
  • Instead of using ::after, I introduced an indicator div, which can be moved within the tabs container.
    • That way, instead of multiple ::after, there will always be just one indicator.
  • I timed the animations so that the background color of .tab.active becomes transparent, and the text color turns white.
    • Due to the transparent background, the new background will be the indicator.
    • Instead of a transparent background, an animation pointing to the indicator's color works well too.

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>
like image 42
rozsazoltan Avatar answered Oct 27 '25 19:10

rozsazoltan



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!