<main>
  <div class="card">
    <a href="#" class="back">&larr;</a>
    <div class="meta">
      <img src="https://brm.us/avatar" alt="Avatar of Bramus" width="800" height="800" class="avatar" draggable="false">
      <div>
        <div class="name">Bramus</div>
        <div class="date">Jan 2024</div>
      </div>
    </div>
    <h1 class="title">Summer Vibes</h1>
    <ul class="moremeta">
      <li>15 songs</li>
      <li>59 minutes</li>
    </ul>
    <div class="description">
      <p>Most popular songs for that summer feeling | Updated weekly | Good vibes only | Photo by Atikh Bana</p>
    </div>
    <img src="https://live-transitions-sda.netlify.app/sax-player.webp" alt="" width="668" height="900" class="cover" draggable="false">
  </div>
  <ul class="tracks">
    <li class="track">
      <img src="https://brm.us/avatar" alt="Album Cover" width="800" height="800" class="album" draggable="false">
      <div class="trackinfo">
        <div class="tracktitle">Who needs to know?</div>
        <div class="artist">Bramus</div>
      </div>
      <div class="more">
        &#9829;
      </div>
    </li>
    <li class="track">
      <img src="https://brm.us/avatar" alt="Album Cover" width="800" height="800" class="album" draggable="false">
      <div class="trackinfo">
        <div class="tracktitle">Who needs to know?</div>
        <div class="artist">Bramus</div>
      </div>
      <div class="more">
        &#9829;
      </div>
    </li>
    <li class="track">
      <img src="https://brm.us/avatar" alt="Album Cover" width="800" height="800" class="album" draggable="false">
      <div class="trackinfo">
        <div class="tracktitle">Who needs to know?</div>
        <div class="artist">Bramus</div>
      </div>
      <div class="more">
        &#9829;
      </div>
    </li>
    <li class="track">
      <img src="https://brm.us/avatar" alt="Album Cover" width="800" height="800" class="album" draggable="false">
      <div class="trackinfo">
        <div class="tracktitle">Who needs to know?</div>
        <div class="artist">Bramus</div>
      </div>
      <div class="more">
        &#9829;
      </div>
    </li>
    <li class="track">
      <img src="https://brm.us/avatar" alt="Album Cover" width="800" height="800" class="album" draggable="false">
      <div class="trackinfo">
        <div class="tracktitle">Who needs to know?</div>
        <div class="artist">Bramus</div>
      </div>
      <div class="more">
        &#9829;
      </div>
    </li>
    <li class="track">
      <img src="https://brm.us/avatar" alt="Album Cover" width="800" height="800" class="album" draggable="false">
      <div class="trackinfo">
        <div class="tracktitle">Who needs to know?</div>
        <div class="artist">Bramus</div>
      </div>
      <div class="more">
        &#9829;
      </div>
    </li>
    <li class="track">
      <img src="https://brm.us/avatar" alt="Album Cover" width="800" height="800" class="album" draggable="false">
      <div class="trackinfo">
        <div class="tracktitle">Who needs to know?</div>
        <div class="artist">Bramus</div>
      </div>
      <div class="more">
        &#9829;
      </div>
    </li>
    <li class="track">
      <img src="https://brm.us/avatar" alt="Album Cover" width="800" height="800" class="album" draggable="false">
      <div class="trackinfo">
        <div class="tracktitle">Who needs to know?</div>
        <div class="artist">Bramus</div>
      </div>
      <div class="more">
        &#9829;
      </div>
    </li>
    <li class="track">
      <img src="https://brm.us/avatar" alt="Album Cover" width="800" height="800" class="album" draggable="false">
      <div class="trackinfo">
        <div class="tracktitle">Who needs to know?</div>
        <div class="artist">Bramus</div>
      </div>
      <div class="more">
        &#9829;
      </div>
    </li>
    <li class="track">
      <img src="https://brm.us/avatar" alt="Album Cover" width="800" height="800" class="album" draggable="false">
      <div class="trackinfo">
        <div class="tracktitle">Who needs to know?</div>
        <div class="artist">Bramus</div>
      </div>
      <div class="more">
        &#9829;
      </div>
    </li>
    <li class="track">
      <img src="https://brm.us/avatar" alt="Album Cover" width="800" height="800" class="album" draggable="false">
      <div class="trackinfo">
        <div class="tracktitle">Who needs to know?</div>
        <div class="artist">Bramus</div>
      </div>
      <div class="more">
        &#9829;
      </div>
    </li>
    <li class="track">
      <img src="https://brm.us/avatar" alt="Album Cover" width="800" height="800" class="album" draggable="false">
      <div class="trackinfo">
        <div class="tracktitle">Who needs to know?</div>
        <div class="artist">Bramus</div>
      </div>
      <div class="more">
        &#9829;
      </div>
    </li>
    <li class="track">
      <img src="https://brm.us/avatar" alt="Album Cover" width="800" height="800" class="album" draggable="false">
      <div class="trackinfo">
        <div class="tracktitle">Who needs to know?</div>
        <div class="artist">Bramus</div>
      </div>
      <div class="more">
        &#9829;
      </div>
    </li>
    <li class="track">
      <img src="https://brm.us/avatar" alt="Album Cover" width="800" height="800" class="album" draggable="false">
      <div class="trackinfo">
        <div class="tracktitle">Who needs to know?</div>
        <div class="artist">Bramus</div>
      </div>
      <div class="more">
        &#9829;
      </div>
    </li>
    <li class="track">
      <img src="https://brm.us/avatar" alt="Album Cover" width="800" height="800" class="album" draggable="false">
      <div class="trackinfo">
        <div class="tracktitle">Who needs to know?</div>
        <div class="artist">Bramus</div>
      </div>
      <div class="more">
        &#9829;
      </div>
    </li>
    <li class="track">
      <img src="https://brm.us/avatar" alt="Album Cover" width="800" height="800" class="album" draggable="false">
      <div class="trackinfo">
        <div class="tracktitle">Who needs to know?</div>
        <div class="artist">Bramus</div>
      </div>
      <div class="more">
        &#9829;
      </div>
    </li>
    <li class="track">
      <img src="https://brm.us/avatar" alt="Album Cover" width="800" height="800" class="album" draggable="false">
      <div class="trackinfo">
        <div class="tracktitle">Who needs to know?</div>
        <div class="artist">Bramus</div>
      </div>
      <div class="more">
        &#9829;
      </div>
    </li>
    <li class="track">
      <img src="https://brm.us/avatar" alt="Album Cover" width="800" height="800" class="album" draggable="false">
      <div class="trackinfo">
        <div class="tracktitle">Who needs to know?</div>
        <div class="artist">Bramus</div>
      </div>
      <div class="more">
        &#9829;
      </div>
    </li>
    <li class="track">
      <img src="https://brm.us/avatar" alt="Album Cover" width="800" height="800" class="album" draggable="false">
      <div class="trackinfo">
        <div class="tracktitle">Who needs to know?</div>
        <div class="artist">Bramus</div>
      </div>
      <div class="more">
        &#9829;
      </div>
    </li>
  </ul>
</main>

<div class="warnings">
  <div class="warning" data-reason="same-document-view-transitions"><p>Your browser does not support Same-Document View Transitions. This demo will not do anything special.</p></div>
</div>
@layer scrollsnapping {
  /* Only allow when VTs are supported because it doesn’t make sense to snap if there is no support */
  @supports (view-transition-name: --works) {
    html {
      scroll-snap-type: y mandatory;

      --card-large: 0; /* Value gets set via JS */
      --card-small: 0; /* Value gets set via JS */
    }

    .card::before, .tracks::before {
      content: "";
      pointer-events: none;
      z-index: -1;
      position: absolute;
      width: 100%;
      top: 0;
      left: 0;
      scroll-snap-align: start;
      z-index: 10;
      opacity: 0.35;
    }

    .card::before {
      height: var(--card-large);
    }

    .tracks {
      position: relative;
    }
    .tracks::before {
      scroll-margin-top: var(--card-small);
      top: var(--card-large);
      height: calc(100% - var(--card-large));
    }
  }
}

@layer viewtransitions {
  /* Configure the durations.

     The idea here is that the card itself runs for a certain duration,
     but elements in that card only for a certain part of that entire duration.
     To achieve this, the durations and delays are expressed as fractions, which
     are then used in a calcuation to get the actual duration in seconds.
  */
  ::view-transition {
    --vt-base-duration: 1s;
    
    --vt-description-duration: 0.5;
    --vt-description-delay: 0;
    
    --vt-moremeta-duration: 0.65;
    --vt-moremeta-delay: 0.2;
    
    --vt-title-duration: 0.6;
    --vt-title-delay: 0.2;
    
    --vt-meta-duration: 0.5;
    --vt-meta-delay: 0.3;
  }
  
  /* Apply base duration to all + make sure they are linear */
  ::view-transition-group(*) {
    animation-duration: var(--vt-base-duration);
    animation-timing-function: linear;
  }
  
  /* Also inherit the delay and easing from the group onto the child pseudos */
  ::view-transition-image-pair(*),
  ::view-transition-new(*),
  ::view-transition-old(*) {
    animation-delay: inherit;
    animation-timing-function: inherit;
  }
  
  /* Allow cursor to send events to underlying page while a VT is running */
  ::view-transition {
    pointer-events: none;
  }
  
  /* Some keyframes to use */
  @keyframes slide-up { to { translate: 0 -100%; }}
  @keyframes slide-down { from { translate: 0 -100%; }}
  @keyframes fade-out { to { opacity: 0; }}
  @keyframes fade-in { from { opacity: 0; }}
  
  /* Capture all these individual elements instead. Also, don’t capture the root. */
  /* Note: we don’t capture the tracklist in this version! */
  :root {
    view-transition-name: none;
  }
  .card {
    view-transition-name: card;
  }
  .meta {
    view-transition-name: meta;
  }
  .title {
    view-transition-name: title;
  }
  .moremeta {
    view-transition-name: moremeta;
  }
  .description {
    view-transition-name: description;
  }
  .cover {
    view-transition-name: cover;
  }
  
  /* The card itself should just shrink, not fade */
  ::view-transition-group(card) {
    overflow: clip;
  }
  ::view-transition-new(card),
  ::view-transition-old(card) {
    animation-name: none;
  }
  
  /* The title and moremeta remain the same. Therefore, don’t fade but immediately use the new snapshot */
  ::view-transition-new(title),
  ::view-transition-new(moremeta) {
    animation-name: none;
  }
  ::view-transition-old(title),
  ::view-transition-old(moremeta) {
    display: none;
  }
  
  /* Slide and fade description. */
  ::view-transition-old(description):only-child {
    animation-duration: calc(var(--vt-base-duration) * var(--vt-description-duration));
    animation-delay: calc(var(--vt-base-duration) * var(--vt-description-delay));
    animation-name: slide-up, fade-out;
  }
  ::view-transition-new(description):only-child {
    animation-duration: calc(var(--vt-base-duration) * var(--vt-description-duration));
    animation-delay: calc(var(--vt-base-duration) * (1 - (var(--vt-description-delay) + var(--vt-description-duration))));
    animation-name: slide-down, fade-in;
  }
  
  /* Set timing for various components */
  ::view-transition-group(moremeta) {
    animation-duration: calc(var(--vt-base-duration) * var(--vt-moremeta-duration));
    animation-delay: calc(var(--vt-base-duration) * var(--vt-moremeta-delay));
  }
  ::view-transition-group(title) {
    animation-duration: calc(var(--vt-base-duration) * var(--vt-title-duration));
    animation-delay: calc(var(--vt-base-duration) * var(--vt-title-delay));
  }
  ::view-transition-group(meta) {
    animation-duration: calc(var(--vt-base-duration) * var(--vt-meta-duration));
    animation-delay: calc(var(--vt-base-duration) * var(--vt-meta-delay));
  }
  ::view-transition-old(meta):only-child {
    animation-name: fade-out;
  }
  ::view-transition-new(meta):only-child {
    animation-name: fade-in;
  }
}


@layer reset {
  * {
    box-sizing: border-box;
  }

  html,
  body,
  ul[class] {
    margin: 0;
    padding: 0;
  }

  html,
  body {
    width: 100%;
  }

  ul[class] {
    list-style: none;
  }

  img {
    max-width: 100%;
    height: auto;
  }
}

@layer baselayout {
  html {
    font-family: system-ui, sans-serif;
    background-color: #f6f6f6;
  }
  main {
    width: 100%;
    min-width: 360px;
    max-width: 500px;
    margin: 0 auto;
  }
  button {
    position: fixed;
    right: 1rem;
    top: 1rem;
    font-size: 1.5em;
    padding: 0.25em 0.5em;
  }
}

@layer card {
  .card {
    background: black;
    color: white;
    text-align: center;
    padding: 2rem 3rem 0;
    position: relative;

    display: grid;
    grid-template:
      "meta" auto
      "title" 100px
      "moremeta" auto
      "description" 80px
      "cover" auto / auto;
    align-items: center;
    gap: 1rem;
  }

  .meta {
    grid-area: meta;
  }
  .title {
    grid-area: title;
  }
  .moremeta {
    grid-area: moremeta;
  }
  .description {
    grid-area: description;
  }
  .cover {
    grid-area: cover;
  }

  .card.small {
    grid-template:
      "cover title" 1fr
      "cover moremeta" 1fr / 80px auto;
    gap: 0;
    padding: 1rem 0 0 3rem;

    .title {
      align-self: end;
    }
    .moremeta {
      align-self: start;
    }

    .description,
    .meta {
      display: none;
    }
  }

  .card * {
    margin: 0;
  }

  .meta {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 1rem;

    .name {
      font-weight: bold;
      text-transform: uppercase;
    }

    .date {
      font-size: 0.8em;
      color: #ccc;
    }
  }

  .avatar {
    display: block;
    width: 50px;
    height: 50px;
    border-radius: 50%;
    outline: 1px solid #fff;
    outline-offset: 3px;
  }

  .moremeta {
    display: flex;
    flex-direction: row;
    gap: 0.5em;
    justify-content: center;
    color: #ccc;
  }

  .description {
    width: 90%;
    margin: 0 auto;
    text-wrap: balance;
    color: #ccc;
  }

  .back {
    position: absolute;
    left: 1rem;
    top: 2rem;
    width: 2rem;
    display: grid;
    justify-content: start;
    text-decoration: none;
    font-size: 2em;
    color: white;
  }
}

@layer tracklist {
  .tracks {
    display: flex;
    flex-direction: column;
  }

  .track {
    padding: 1em;
    display: flex;
    flex-direction: row;
    gap: 1em;
    
    &:hover {
      background: #eee;
    }
  }
  
  .album {
    display: block;
    width: 4em;
    aspect-ratio: 1;
    border-radius: 0.25em;
  }
  
  .trackinfo {
    flex: 1;
    
    display: flex;
    flex-direction: column;
    justify-content: center;
    
    .tracktitle {
      font-weight: bold;
    }
  }
  
  .more {
    justify-self: end;
    color: #ccc;
    font-size: 2rem;
    
    display: flex;
    flex-direction: column;
    justify-content: center;
    
    &:hover {
      cursor: pointer;
      color: red;
    }
  }
}


@layer warnings {
  /* Warnings and Preferences */
  @media (prefers-reduced-motion: reduce) {
    .warning[data-reason="prefers-reduced-motion"] {
      display: block;
    }
  }

  @supports not (view-transition-name: --works) {
    .warning[data-reason="same-document-view-transitions"] {
      display: block;
    }
  }

  @supports not (scroll-timeline-name: --works) {
    .warning[data-reason="scroll-driven-animations"] {
      display: block;
    }
  }

  .warnings {
    font-family: system-ui, sans-serif;
    position: fixed;
    bottom: 0;
    left: 1em;
    right: 1em;
    z-index: 2;
  }

  @layer warning {
    .warning {
      box-sizing: border-box;
      padding: 1em;
      border: 1px solid #ccc;
      background: rgba(255 255 205 / 0.8);
      display: none;
      margin: 1em;
      text-align: center;
      text-wrap: balance;
    }

    .warning > :first-child {
      margin-top: 0;
    }

    .warning > :last-child {
      margin-bottom: 0;
    }

    .warning a {
      color: blue;
    }
    .warning--info {
      border: 1px solid #123456;
      background: rgb(205 230 255 / 0.8);
    }
    .warning--alarm {
      border: 1px solid red;
      background: #ff000010;
    }
  }
}
// This version:
// - Same as V1 (https://codepen.io/bramus/pen/bGZWwxJ)
//   - Starts a VT that remains active
//   - Adds a scroll listener that updates the VT’s animations based on the scroll position via scrollTop
//   - Re-initializes everything on resize, as ongoing VTs get cancelled on resize
// - Only creates the VT when in range


const go = () => {
  let activeViewTransition = null;
  let activeAnimations = [];
  
  const $card = document.querySelector('.card');
  const $tracks = document.querySelector('.tracks');
  
  const cardHeight = CSS.px($card.offsetHeight);
  const cardWidth = CSS.px($card.offsetWidth);
  const cardHeightSmall = CSS.px(120); // @TODO: Make this dynamic
  
  document.documentElement.style.setProperty('--card-large', cardHeight.toString());
  document.documentElement.style.setProperty('--card-small', cardHeightSmall.toString());

  // Determine things to track
  const scrollTimelineAxis = 'y';
  let scrollTimelineStart = CSS.px(0);
  let scrollTimelineEnd = cardHeight.sub(cardHeightSmall);

  // Make the card faux-sticky, and offset the tracks
  $card.style.width = cardWidth;
  $card.style.position = 'fixed';
  $card.style.zIndex = '1';
  $card.style.top = '0';
  $tracks.style.paddingTop = cardHeight;
  
  // The base duration of the animation
  // Same as --vt-base-duration in the CSS
  const baseDuration = 1000;
    
  const scrollDistance = scrollTimelineEnd.sub(scrollTimelineStart);
  
  // Method that starts the View Transition
  const startViewTransition = async () => {
    // Determine if we are going back or not
    const isReverse = document.querySelector('.small') ? true : false;
        
    // Start the View Transition
    activeViewTransition = document.startViewTransition(() => {
      document.querySelector('.card').classList.toggle('small');
    });
    await activeViewTransition.ready;
    
    // Immediately pause all animations linked to it
    activeAnimations = document.getAnimations().filter((anim) =>
      anim.effect.target === document.documentElement && anim.effect.pseudoElement?.startsWith("::view-transition")
    );
    for (const anim of activeAnimations) {
      if (isReverse) anim.reverse();
      anim.pause();
    }
    
    // Make sure animations their currentTime is up-to-date
    updateAnimations();

    // The VT finishes when all the animations have reached 100%,
    // i.e. when having scroll past the scrollTimelineEnd offset.
    await activeViewTransition.finished;

    // Make sure the card has the correct end state
    if (document.documentElement.scrollTop > scrollTimelineStart.value) {
      document.querySelector('.card').classList.add('small');
    } else {
      document.querySelector('.card').classList.remove('small');
    }

    // Clear activeViewTransition so that scroll listener can create a new VT once in the correct range
    activeViewTransition = null;
  }
  
  // Method that updates the tracked animations
  const updateAnimations = () => {
    // No need to do anything when there are no animations being tracked
    if (!activeAnimations.length) return;
    
    const scrollProgress = (document.documentElement.scrollTop - scrollTimelineStart.value) / scrollDistance.value;
  
    // Determine time based on dragging delta
    // Clamp it between 0 and baseDuration.
    const currentTime = Math.max(0, Math.min(baseDuration, scrollProgress * baseDuration));

    for (const animation of activeAnimations) {
      // Take playbackRate into account
      if (animation.playbackRate === -1) {
        animation.currentTime = baseDuration - currentTime;
      } else {
        animation.currentTime = currentTime;
      }
    }
    
  }
  
  const checkScrollPosition = async () => {
    // In-range: start or update the VT
    if (
      (document.documentElement.scrollTop > scrollTimelineStart.value) && 
      (document.documentElement.scrollTop < scrollTimelineEnd.value)
    ) {
      if (!activeViewTransition) {
        startViewTransition();
      } else {
        updateAnimations();
      }
    }
    
    // Outside of the range: clean up the VT
    else {

      // Explicitly clear the VT when outside the range.
      // This because when undershooting the scrollTimelineStart offset, the VT doesn’t finish automatically
      // (When overshooting the scrollTimelineEnd offset it does cancel automatically)
      if (activeViewTransition) {
        activeViewTransition.skipTransition();
        activeViewTransition = null;
      }
            
      // Make sure the card has the correct class depending on the scroll offset
      if (document.documentElement.scrollTop >= scrollTimelineEnd.value) {
        document.querySelector('.card:not(.small)')?.classList.add('small');
      } else {
        document.querySelector('.card.small')?.classList.remove('small');
      }
    }
  }
    
  // Add a scroll listener, and update the viewTransition based on the active offset
  window.addEventListener('scroll', checkScrollPosition);
  
  // On resize View Transitions get cancelled, so we need to make sure we’re at the correct state again
  // @TODO: Debounce this
  window.addEventListener('resize', checkScrollPosition);
  
  // Make sure the card has the correct class when already at the specific offset
  checkScrollPosition();
}

window.addEventListener('load', () => {
  go();
});

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

This Pen doesn't use any external JavaScript resources.