Created
November 14, 2025 19:27
-
-
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)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <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: <custom-ident>+</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> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | |
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| @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