From c411cde0bb81bc371bf2760c48a3895d613a9524 Mon Sep 17 00:00:00 2001 From: Mattias Bodlund Date: Sun, 30 Aug 2026 11:08:44 +0200 Subject: [PATCH] desktop animations --- app/assets/stylesheets/application.css | 81 ++++++++++++++++++++++---- app/javascript/answer_controller.js | 44 ++++++++++---- app/javascript/application.js | 12 +++- 3 files changed, 113 insertions(+), 24 deletions(-) diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index d137dcb..62443e9 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -367,6 +367,11 @@ main, .modal-panel { to { transform: translateY(0); } } +@keyframes screen-push-left { + from { transform: translateX(0); } + to { transform: translateX(-100vw); } +} + @media (max-width: 1023.98px) and (prefers-reduced-motion: no-preference) { .start .hero-container { overflow: clip; @@ -674,28 +679,28 @@ main, .modal-panel { opacity: 0; } - html[data-screen-transition="stage-in-wide"] main { + html:is([data-screen-transition="stage-in-wide"], [data-screen-transition="results-in-wide"]) main { view-transition-name: game; } - html[data-screen-transition="stage-in-wide"]::view-transition-old(root), - html[data-screen-transition="stage-in-wide"]::view-transition-new(root), - html[data-screen-transition="stage-in-wide"]::view-transition-old(game), - html[data-screen-transition="stage-in-wide"]::view-transition-new(game) { + html:is([data-screen-transition="stage-in-wide"], [data-screen-transition="results-in-wide"])::view-transition-old(root), + html:is([data-screen-transition="stage-in-wide"], [data-screen-transition="results-in-wide"])::view-transition-new(root), + html:is([data-screen-transition="stage-in-wide"], [data-screen-transition="results-in-wide"])::view-transition-old(game), + html:is([data-screen-transition="stage-in-wide"], [data-screen-transition="results-in-wide"])::view-transition-new(game) { mix-blend-mode: normal; } - html[data-screen-transition="stage-in-wide"]::view-transition-old(root), - html[data-screen-transition="stage-in-wide"]::view-transition-new(root), - html[data-screen-transition="stage-in-wide"]::view-transition-old(game) { + html:is([data-screen-transition="stage-in-wide"], [data-screen-transition="results-in-wide"])::view-transition-old(root), + html:is([data-screen-transition="stage-in-wide"], [data-screen-transition="results-in-wide"])::view-transition-new(root), + html:is([data-screen-transition="stage-in-wide"], [data-screen-transition="results-in-wide"])::view-transition-old(game) { animation: none; } - html[data-screen-transition="stage-in-wide"]::view-transition-new(game) { + html:is([data-screen-transition="stage-in-wide"], [data-screen-transition="results-in-wide"])::view-transition-new(game) { animation: screen-slide-in var(--dur-base) var(--ease-out) both; } - html[data-screen-transition="stage-in-wide"] .stage .hero-container img { + html[data-screen-transition="stage-in-wide"] :is(.stage, .last_save) .hero-container img { view-transition-name: hero-image; } @@ -704,11 +709,11 @@ main, .modal-panel { mix-blend-mode: normal; } - html[data-screen-transition="stage-in-wide"] .stage .hero-container svg.ico-wave { + html[data-screen-transition="stage-in-wide"] :is(.stage, .last_save) .hero-container svg.ico-wave { view-transition-name: hero-wave; } - html[data-screen-transition="stage-in-wide"] .stage .hero-container #inter-ikea-systems { + html[data-screen-transition="stage-in-wide"] :is(.stage, .last_save) .hero-container #inter-ikea-systems { view-transition-name: hero-logo; } @@ -724,6 +729,58 @@ main, .modal-panel { html[data-screen-transition="stage-in-wide"]::view-transition-new(hero-logo) { animation: hero-detail-slide-in var(--dur-base) var(--ease-out) both; } + + html[data-screen-transition="answer-wide"]::view-transition-old(root), + html[data-screen-transition="answer-wide"]::view-transition-new(root) { + animation-duration: var(--dur-base); + animation-timing-function: linear; + } + + html[data-screen-transition="answer-wide"] .answers-container > form:nth-of-type(1) { + view-transition-name: answer-1; + } + + html[data-screen-transition="answer-wide"] .answers-container > form:nth-of-type(2) { + view-transition-name: answer-2; + } + + html[data-screen-transition="answer-wide"] .answers-container > form:nth-of-type(3) { + view-transition-name: answer-3; + } + + html[data-screen-transition="answer-wide"] .answers-container > form:nth-of-type(4) { + view-transition-name: answer-4; + } + + html[data-screen-transition="answer-wide"]::view-transition-old(answer-1), + html[data-screen-transition="answer-wide"]::view-transition-old(answer-2), + html[data-screen-transition="answer-wide"]::view-transition-old(answer-3), + html[data-screen-transition="answer-wide"]::view-transition-old(answer-4) { + animation: answers-sink calc(var(--dur-base) * 0.6) var(--ease-in) both; + mix-blend-mode: normal; + } + + html:has(:is(.good_answer, .bad_answer):is(.is-exiting, .is-playing)) { + overflow: hidden; + } + + :is(.good_answer, .bad_answer).is-exiting { + animation: screen-push-left var(--dur-base) var(--ease-out) both; + } + + :is(.good_answer, .bad_answer):is(.is-exiting, .is-playing) .hero-container video { + position: absolute; + inset: 0; + width: 100vw; + max-width: none; + height: 100vh; + opacity: 1; + z-index: 3; + } + + :is(.good_answer, .bad_answer).is-exiting .hero-container video { + transform: translateX(100vw); + } } diff --git a/app/javascript/answer_controller.js b/app/javascript/answer_controller.js index 1cc691d..b0ec191 100644 --- a/app/javascript/answer_controller.js +++ b/app/javascript/answer_controller.js @@ -1,8 +1,10 @@ import { Controller } from "@hotwired/stimulus" -// The animation this drives only exists on small screens, and never for -// someone who asked for less motion. Anywhere else the link just navigates. -const MOTION = "(max-width: 1023.98px) and (prefers-reduced-motion: no-preference)" +// Neither layout animates for someone who asked for less motion; the two that +// do part ways on the video, which the narrow screen grows into place and the +// wide one carries in on the screen's own slide. +const MOTION = "(prefers-reduced-motion: no-preference)" +const NARROW = "(max-width: 1023.98px)" export default class extends Controller { static targets = ["video"] @@ -15,21 +17,26 @@ export default class extends Controller { if (!this.hasVideoTarget) return if (!window.matchMedia(MOTION).matches) return + this.narrow = window.matchMedia(NARROW).matches + event.preventDefault() this.link = event.currentTarget - const answers = this.element.querySelector(".answers-container") - if (answers) this.element.style.setProperty("--exit-shift", `${answers.offsetHeight}px`) + if (this.narrow) { + const answers = this.element.querySelector(".answers-container") + if (answers) this.element.style.setProperty("--exit-shift", `${answers.offsetHeight}px`) - // The last video hands over to the results rather than to another stage, so - // the header leaves with the copy and the video takes the whole screen. - if (event.params.full) { - const header = this.element.querySelector(".stage-header-container") - if (header) this.element.style.setProperty("--header-shift", `${header.offsetHeight}px`) - this.element.classList.add("is-finishing") + // The last video hands over to the results rather than to another stage, so + // the header leaves with the copy and the video takes the whole screen. + if (event.params.full) { + const header = this.element.querySelector(".stage-header-container") + if (header) this.element.style.setProperty("--header-shift", `${header.offsetHeight}px`) + this.element.classList.add("is-finishing") + } } this.element.classList.add("is-exiting") + if (!this.narrow) this.element.addEventListener("animationend", this.land) this.videoTarget.addEventListener("timeupdate", this.checkTail) this.videoTarget.addEventListener("ended", this.follow, { once: true }) @@ -37,6 +44,17 @@ export default class extends Controller { this.videoTarget.play().catch(this.follow) } + // The screen is pushed off to the left with the video riding in beside it, so + // once that lands the push is undone: the video ends up square in the viewport + // and the screen back under it, which is the frame the next transition + // captures and holds still while the stage slides in over it. + land = (event) => { + if (event && event.target !== this.element) return + + this.element.removeEventListener("animationend", this.land) + this.element.classList.replace("is-exiting", "is-playing") + } + // The next stage assembles itself over the tail of the video rather than // after it, so the last frame is still moving while the new screen arrives. checkTail = () => { @@ -51,6 +69,10 @@ export default class extends Controller { this.leaving = true this.videoTarget.removeEventListener("timeupdate", this.checkTail) + + // A video shorter than the slide would otherwise be captured mid-push. + if (!this.narrow) this.land() + this.link?.click() } diff --git a/app/javascript/application.js b/app/javascript/application.js index 6fd2780..ea1feab 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -50,7 +50,17 @@ const SCREEN_TRANSITIONS = { // The desktop layout is a different composition -- two columns rather than one // stack -- so the pairs that animate there, and how, are their own map. const WIDE_SCREEN_TRANSITIONS = { - "intro>stage": "stage-in-wide" + "intro>stage": "stage-in-wide", + "stage>good_answer": "answer-wide", + "stage>bad_answer": "answer-wide", + "good_answer>stage": "stage-in-wide", + "bad_answer>stage": "stage-in-wide", + "good_answer>last_save": "stage-in-wide", + "bad_answer>last_save": "stage-in-wide", + "last_save>good_answer": "answer-wide", + "last_save>bad_answer": "answer-wide", + "good_answer>results": "results-in-wide", + "bad_answer>results": "results-in-wide" } const TRANSITION_MEDIA = "(max-width: 1023.98px) and (prefers-reduced-motion: no-preference)"