Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save rob-balfre/e6501e0f9ddf2deaa0074510aa73ffa1 to your computer and use it in GitHub Desktop.

Select an option

Save rob-balfre/e6501e0f9ddf2deaa0074510aa73ffa1 to your computer and use it in GitHub Desktop.
Add/Remove Cards with View Transitions in scroller (with nested view transition groups)

Add/Remove Cards with View Transitions in scroller (with nested view transition groups)

A Pen by web.dev on CodePen.

License.

<div class="options">
<label><input type="checkbox" id="nested" checked> Clip cards by wrapper (Nested View Transition Groups)</label>
<label><input type="checkbox" id="in-scrollport"> Only snapshot cards when visible in the scrollport (Scroll-Driven Animations)</label>
</div>
<button class="add-btn">
<span class="sr-only">Add</span>
</button>
<template id="card">
<li class="card">
<button class="delete-btn">
<span class="sr-only">Delete</span>
</button>
</li>
</template>
<ul class="cards">
<li class="card" style="background-color: tan;">
<button class="delete-btn">
<span class="sr-only">Delete</span>
</button>
</li>
<li class="card" style="background-color: khaki;">
<button class="delete-btn">
<span class="sr-only">Delete</span>
</button>
</li>
<li class="card" style="background-color: thistle;">
<button class="delete-btn">
<span class="sr-only">Delete</span>
</button>
</li>
<li class="card" style="background-color: wheat;">
<button class="delete-btn">
<span class="sr-only">Delete</span>
</button>
</li>
</ul>
<div class="warning" data-for="view-transition-class">
<p>Your browser does not support <code>view-transtion-class: &lt;custom-ident&gt;+</code>. As a result, the existing cards will not bounce upon inserting/deleting a card.</p>
</div>
<div class="warning" data-for="nested-view-transition-groups">
<p>Your browser does not support <code>view-transtion-group</code>. As a result, the cards will bleed out their container while the view transition runs.</p>
</div>
<footer>
<p>Demo for <a href="https://goo.gle/nested-view-transition-groups" target="_top">https://goo.gle/nested-view-transition-groups</a></p>
</footer>
document.querySelector('.cards').addEventListener('click', e => {
if (e.target.classList.contains('delete-btn')) {
if (!document.startViewTransition) {
e.target.parentElement.remove();
return;
}
e.target.parentElement.id = 'targeted-card';
document.startViewTransition(() => {
e.target.parentElement.remove();
});
}
})
document.querySelector('.add-btn').addEventListener('click', async (e) => {
const template = document.getElementById('card');
const $newCard = template.content.cloneNode(true);
$newCard.firstElementChild.style.backgroundColor = `#${ Math.floor(Math.random()*16777215).toString(16)}`;
if (!document.startViewTransition) {
document.querySelector('.cards').appendChild($newCard);
return;
}
$newCard.firstElementChild.id = 'targeted-card';
const transition = document.startViewTransition(() => {
document.querySelector('.cards').appendChild($newCard);
document.querySelector('.cards').scrollLeft = 9999999;
});
await transition.ready;
// Remove v-t-name set by JS, so that the CSS value gets taken
document.querySelector('.cards .card:last-child').id = null;
});
@layer view-transitions {
/* Don’t capture the root, allowing pointer interaction while cards are animating */
@layer no-root {
:root {
view-transition-name: none;
}
::view-transition {
pointer-events: none;
}
}
/* Cards, in general, should use a bounce effect when moving to their new position */
@layer reorder-cards {
.card {
view-transition-name: match-element;
}
@supports (animation-timeline: view()) {
@keyframes name-when-in-scrollport {
0% { view-transition-name: match-element; }
}
html:has(#in-scrollport:checked) {
/* This applies a v-t-n only to elements that are visible inside the scrollport.
NOTE: I have excluded a few items here:
- The targeted card: it always needs its special v-t-n.
- The last 10 items in the list: Without it, when you’ve added a bunch of items,
then scroll back to offsetLeft 0, then add a card, it would look … off. This
because the last items in the list were out of view and would not get a name.
And since cards get added at the end, we need to make sure to always give them
a name. The amount of items to exclude (10) was arbitrarily chosen because you
can’t predict (in CSS) how many items the scrollport can show, because the wrapper
has a dynamic width. (I’m sure people more clever than me can find a way. Looking
at you Ana, Amit, Temani, …)
*/
.card:not(#targeted-card):not(.card:nth-last-child(-n+10)) {
view-transition-name: none;
animation: name-when-in-scrollport steps(1);
animation-timeline: view(inline);
animation-range: entry -150px exit 150px;
}
}
}
@supports (view-transition-class: card) {
.warning[data-for="view-transition-class"] {
display: none;
}
:root {
--bounce-easing: linear(
0, 0.004, 0.016, 0.035, 0.063, 0.098, 0.141 13.6%, 0.25, 0.391, 0.563, 0.765,
1, 0.891 40.9%, 0.848, 0.813, 0.785, 0.766, 0.754, 0.75, 0.754, 0.766, 0.785,
0.813, 0.848, 0.891 68.2%, 1 72.7%, 0.973, 0.953, 0.941, 0.938, 0.941, 0.953,
0.973, 1, 0.988, 0.984, 0.988, 1
);
}
.card {
/* view-transition-name: match-element; */
view-transition-class: card;
}
/* Without view-transition-class you had to write a selector that targets all cards … and that selector needed updating whenever you added/removed a card */
::view-transition-group(*.card) {
animation-timing-function: var(--bounce-easing);
animation-duration: 0.5s;
}
}
}
/* Give the targeted card a view-transition-name */
@layer add-or-remove-card {
#targeted-card {
view-transition-name: targeted-card;
}
}
/* Newly added cards should animate-in */
@layer add-card {
@keyframes animate-in {
0% {
opacity: 0;
translate: 0 -200px;
}
100% {
opacity: 1;
translate: 0 0;
}
}
::view-transition-new(targeted-card):only-child {
animation: animate-in ease-in 0.25s forwards;
}
}
/* Cards that get removed should animate-out */
@layer remove-card {
@keyframes animate-out {
0% {
opacity: 1;
translate: 0 0;
}
100% {
opacity: 0;
translate: 0 -200px;
}
}
::view-transition-old(targeted-card):only-child {
animation: animate-out ease-out 0.5s forwards;
}
}
/* Use Nested View Transition Groups */
@layer nested-vt-groups {
@supports (view-transition-group: nearest) {
.warning[data-for="nested-view-transition-groups"] {
display: none;
}
/* Contain children groups of the cards wrapper inside of it */
/* This also requires the cards wrapper to be captured as part of the VT */
html:has(#nested:checked) {
.cards {
view-transition-name: cards;
view-transition-group: contain;
}
/* Clip the contents of the ::view-transition-group-children pseudo. */
/* That way, its nested groups don’t bleed out of their container */
&::view-transition-group-children(cards) {
overflow: clip;
}
/* The targeted card should not be contained by the ::view-transition-group-children(cards) pseudo */
#targeted-card {
view-transition-group: none;
}
}
}
}
}
/* Etc. */
@layer base {
* {
box-sizing: border-box;
}
body {
display: grid;
height: 90dvh;
place-content: safe center;
padding: 2rem 0;
gap: 2rem;
font-family: system-ui, sans-serif;
width: 100%;
}
.cards {
padding: 0;
display: flex;
justify-content: safe center;
width: 80vw;
max-width: 60rem;
border: 1px solid #ccc;
gap: 2rem;
padding: 1rem 2rem;
overflow-y: auto;
overscroll-behavior: contain;
min-height: 10rem;
}
.card {
width: 100px;
flex: 0 0 100px;
aspect-ratio: 2/3;
display: block;
position: relative;
border-radius: 1rem;
background-color: grey;
}
.delete-btn {
--icon: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20height%3D%2224px%22%20viewBox%3D%220%20-960%20960%20960%22%20width%3D%2224px%22%20fill%3D%22%23000%22%3E%3Cpath%20d%3D%22M280-120q-33%200-56.5-23.5T200-200v-520h-40v-80h200v-40h240v40h200v80h-40v520q0%2033-23.5%2056.5T680-120H280Zm400-600H280v520h400v-520ZM360-280h80v-360h-80v360Zm160%200h80v-360h-80v360ZM280-720v520-520Z%22%2F%3E%3C%2Fsvg%3E");
position: absolute;
bottom: -0.75rem;
right: -0.75rem;
width: 3rem;
height: 3rem;
padding: 0.5rem;
border: 4px solid;
border-radius: 100%;
background: aliceblue var(--icon) no-repeat 50% 50% / 70%;
color: white;
cursor: pointer;
&:hover {
background-color: orangered;
}
}
.add-btn {
--icon: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20height%3D%2224px%22%20viewBox%3D%220%20-960%20960%20960%22%20width%3D%2224px%22%20fill%3D%22%23000%22%3E%3Cpath%20d%3D%22M440-440H200v-80h240v-240h80v240h240v80H520v240h-80v-240Z%22%2F%3E%3C%2Fsvg%3E");
width: 3rem;
height: 3rem;
padding: 0.5rem;
border: 4px solid;
border-radius: 100%;
background: aliceblue var(--icon) no-repeat 50% 50% / 70%;
color: white;
cursor: pointer;
margin: 0 auto;
&:hover {
background-color: cornflowerblue;
}
}
.sr-only {
border: 0;
clip: rect(1px, 1px, 1px, 1px);
clip-path: inset(50%);
height: 1px;
margin: -1px;
overflow: hidden;
padding: 0;
position: absolute;
width: 1px;
white-space: nowrap;
}
footer {
text-align: center;
font-style: italic;
line-height: 1.42;
}
a,
a:visisted {
color: blue;
}
a:hover,
a:focus,
a:active {
color: navy;
}
}
@layer options {
.options {
position: fixed;
left: 0;
top: 0;
padding: 1em;
opacity: 0.5;
&:hover {
opacity: 1;
}
label {
display: block;
cursor: pointer;
}
}
}
}
@layer warning {
.warning {
padding: 1em;
margin: 1em 0;
border: 1px solid #ccc;
background: rgba(255 255 205 / 0.8);
text-align: center;
}
.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;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment