<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>
@layer scrollsnapping {
	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;
		}
	}
}
// 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.