/* ============================================================ Screen 06: motion energy. One scene, three energies. A synthetic pointer walks the same route through the same wireframe on all three options and clicks the same two things: the second of the two hero buttons, and a card in the gallery. What differs is what the page does about it. Two clicks, not four. A visitor comparing three energies is reading the difference between them, and a scene with something happening every second spends the visitor's attention on following the pointer instead. The two that are left are the two where the energies actually separate: a control answering a press, and a selection that either lands or is staged. The whole scene runs on one 8s timeline with every beat written as a percentage of it, and nothing is offset by animation-delay. That is what keeps an orchestrated response in step after an hour of looping, and it is why a frozen frame of this screen is always coherent. The three options are not three sets of keyframes. They set the amplitude of the shared ones: how far a press travels, whether an entrance starts from nothing, whether siblings recede, and above all whether a state change steps or eases. Restrained's step-start puts every change on the frame the click lands, which is what "state changes only" means in practice. The script restarts the timeline whenever the previewed option changes. The entrance is the loudest difference between the three, and it happens in the first fifth of the loop: without a restart, a visitor who hovers a row four seconds in is comparing two energies by their press depth alone. ============================================================ */ .picker-preview-motion { --mt: 8s; /* Ease-out-cubic, not the expo curve this started on. Expo puts four fifths of the travel in the first fifth of the duration, so lengthening a band it runs on buys a longer tail and not a slower arrival. */ --mt-ease: cubic-bezier(0.33, 1, 0.68, 1); /* Restrained is the default state of every slot below: changes land whole, nothing travels, nothing arrives. */ --mt-tf: steps(1, start); --mt-press-scale: 1; --mt-lift: 0px; --mt-dim: 1; --mt-sink: 0px; --mt-in-o: 1; --mt-in-y: 0px; --mt-in-clip: 0%; } /* Hover previews the energy before the click commits it, same as the color strategy rows. Motion is the one answer that does not travel to the screens after it: a page still moving is a page you cannot judge a grid on. */ /* State changes get a gentler curve than movement does. The deceleration curve below is right for something arriving from somewhere, but it puts half a color cross-fade in the first 25ms, which is another way of spelling instant. */ #picker-form:has(.picker-strategy-option:hover input[name="motion-energy"][value="responsive"]) .picker-preview-motion, #picker-form:not(:has(.picker-strategy-option:hover input[name="motion-energy"])):has(input[name="motion-energy"][value="responsive"]:checked) .picker-preview-motion { --mt-tf: ease; --mt-press-scale: 0.94; } /* The siblings recede by 0.78 and 2px rather than by the 0.45 this started at. Dimming is composited against whatever ground the color strategy chose, and against a saturated primary a deep dim drags a brown thumbnail into olive and a gold one into moss: the gallery stops reading as four cards standing back and starts reading as a palette bug. Held this shallow the hue survives on every ground, and the 2px carries the recession that the missing opacity was carrying, in a channel no palette can distort. */ #picker-form:has(.picker-strategy-option:hover input[name="motion-energy"][value="choreographed"]) .picker-preview-motion, #picker-form:not(:has(.picker-strategy-option:hover input[name="motion-energy"])):has(input[name="motion-energy"][value="choreographed"]:checked) .picker-preview-motion { --mt-tf: ease; --mt-press-scale: 0.94; --mt-lift: -4px; --mt-dim: 0.78; --mt-sink: 2px; --mt-in-o: 0; --mt-in-y: 14px; --mt-in-clip: 100%; } /* Size containment turns the frame into the coordinate system: the pointer's route is written in cqw and cqh, so it lands on the same button at every viewport width without a line of script. */ .picker-preview-motion .ps-desktop { position: relative; container-type: size; } /* ── The pointer ─────────────────────────────────────────────── */ .picker-preview-motion .ps-cursor { position: absolute; top: 0; left: 0; width: 13px; height: 13px; background-color: var(--pvs-ink); clip-path: polygon(0 0, 0 76%, 24% 58%, 41% 100%, 58% 92%, 41% 50%, 74% 42%); filter: drop-shadow(0.5px 0.5px 0 var(--pvs-ground)); animation: mt-path var(--mt) ease-in-out infinite; } /* The click has to be visible or the state change looks unprovoked. This ring belongs to the pointer rather than to the page, so it reads the same on all three options: it is the visitor's hand, not the design's motion. */ .picker-preview-motion .ps-cursor::after { content: ""; position: absolute; top: -4px; left: -4px; width: 18px; height: 18px; border: 1.5px solid var(--pvs-ink); border-radius: 50%; opacity: 0; animation: mt-click var(--mt) linear infinite; } /* Parked below the page until the entrance has finished, because a pointer moving through a page that is still arriving is two things to watch. */ @keyframes mt-path { 0%, 24% { translate: 34cqw 96cqh; } 33%, 36% { translate: 21.8cqw 45.8cqh; } 50%, 54% { translate: 73.8cqw 80.2cqh; } 70%, 100% { translate: 34cqw 96cqh; } } @keyframes mt-click { 0%, 33.6% { scale: 0.35; opacity: 0; } 34% { scale: 0.35; opacity: 0.55; } 36.4% { scale: 1.5; opacity: 0; } 50.6% { scale: 0.35; opacity: 0; } 51% { scale: 0.35; opacity: 0.55; } 53.4%, 100% { scale: 1.5; opacity: 0; } } /* ── Beat one: the hero buttons as one control, at 34% ───────── */ /* The pressed button takes the fill on the frame of the click and the one it came from fades out behind it, which is the order that keeps a filled button on the page throughout. Cross-fading the pair in opposite directions instead is where the wash came from: a fill and a ghost sit a long way apart in lightness once the strategy is drenched, primary as the ground puts the filled state on the page's ink, and for the length of the fade the hero held two buttons of a grey belonging to neither. */ .picker-preview-motion .ps-actions i:first-child { animation: mt-seg-off var(--mt) var(--mt-tf) infinite; } .picker-preview-motion .ps-actions i:last-child { animation: mt-seg-on var(--mt) steps(1, start) infinite, mt-press-seg var(--mt) linear infinite; } /* 120ms for the fade out, and the reset gets the same. */ @keyframes mt-seg-off { 0%, 34% { background-color: var(--pvs-cta); } 35.5%, 88% { background-color: var(--pvs-ghost); } 89.5%, 100% { background-color: var(--pvs-cta); } } @keyframes mt-seg-on { 0%, 34% { background-color: var(--pvs-ghost); } 35.5%, 88% { background-color: var(--pvs-cta); } 89.5%, 100% { background-color: var(--pvs-ghost); } } @keyframes mt-press-seg { 0%, 33.6% { scale: 1; } 34.4% { scale: var(--mt-press-scale); } 36% { scale: 1; } 100% { scale: 1; } } /* ── Beat two: the card at 51% ───────────────────────────────── */ /* Where the three energies separate most. All three mark the card as chosen. Responsive adds the press. Choreographed lifts it off the page and takes its neighbours back 80ms later, which is the difference between feedback and something staged. The fill starts as the chosen surface at zero alpha rather than as the keyword: `transparent` is a black nobody asked for, and a cross-fade through it is how a green page comes to flash a grey card. */ .picker-preview-motion .ps-gallery-item:nth-child(3) { animation: mt-card-on var(--mt) var(--mt-tf) infinite, mt-card-lift var(--mt) var(--mt-tf) infinite, mt-press-card var(--mt) linear infinite; } .picker-preview-motion .ps-gallery-item:not(:nth-child(3)) { animation: mt-card-dim var(--mt) var(--mt-tf) infinite; } @keyframes mt-card-on { 0%, 51% { background-color: color-mix(in oklab, var(--pvs-surface) 0%, transparent); box-shadow: inset 0 0 0 0 var(--pvs-cta); } 52.5%, 88% { background-color: var(--pvs-surface); box-shadow: inset 0 0 0 1.5px var(--pvs-cta); } 89.5%, 100% { background-color: color-mix(in oklab, var(--pvs-surface) 0%, transparent); box-shadow: inset 0 0 0 0 var(--pvs-cta); } } /* Lift and press are on the independent properties rather than both on transform, so one can run on the option's curve while the other stays linear. */ @keyframes mt-card-lift { 0%, 51% { translate: 0 0; } 53.5%, 88% { translate: 0 var(--mt-lift); } 89.5%, 100% { translate: 0 0; } } @keyframes mt-press-card { 0%, 50.6% { scale: 1; } 51.4% { scale: var(--mt-press-scale); } 53% { scale: 1; } 100% { scale: 1; } } @keyframes mt-card-dim { 0%, 52% { filter: opacity(1); translate: 0 0; } 54%, 88% { filter: opacity(var(--mt-dim)); translate: 0 var(--mt-sink); } 89.5%, 100% { filter: opacity(1); translate: 0 0; } } /* ── The entrance, for the option that has one ───────────────── */ /* Six bands down the page, 200ms apart, each arriving over 560ms: 1.56s from the first movement to the last. Slow enough to watch a band arrive and name what arrived, and staggered widely enough to read as a sequence rather than as one block fading in, which is the whole point of showing it. Restrained and Responsive run the same animations with the travel set to zero, so the page is simply already there. The bands are containers rather than leaves. Every element that changes state during the loop is inside one, so an entrance never has to share a property with an interaction. */ .picker-preview-motion .ps-nav { animation: mt-in-1 var(--mt) var(--mt-ease) infinite; } .picker-preview-motion .ps-hero-copy > * { animation: mt-in-2 var(--mt) var(--mt-ease) infinite; } .picker-preview-motion .ps-hero > .ps-image { animation: mt-in-image var(--mt) var(--mt-ease) infinite; } .picker-preview-motion .ps-proof-item { animation: mt-in-4 var(--mt) var(--mt-ease) infinite; } .picker-preview-motion .ps-editorial-copy > * { animation: mt-in-5 var(--mt) var(--mt-ease) infinite; } .picker-preview-motion .ps-gallery { animation: mt-in-5 var(--mt) var(--mt-ease) infinite; } .picker-preview-motion .ps-footer { animation: mt-in-6 var(--mt) var(--mt-ease) infinite; } @keyframes mt-in-1 { 0% { opacity: var(--mt-in-o); translate: 0 var(--mt-in-y); } 7%, 95% { opacity: 1; translate: 0 0; } 100% { opacity: var(--mt-in-o); translate: 0 0; } } @keyframes mt-in-2 { 0%, 2.5% { opacity: var(--mt-in-o); translate: 0 var(--mt-in-y); } 9.5%, 95% { opacity: 1; translate: 0 0; } 100% { opacity: var(--mt-in-o); translate: 0 0; } } @keyframes mt-in-4 { 0%, 7.5% { opacity: var(--mt-in-o); translate: 0 var(--mt-in-y); } 14.5%, 95% { opacity: 1; translate: 0 0; } 100% { opacity: var(--mt-in-o); translate: 0 0; } } @keyframes mt-in-5 { 0%, 10% { opacity: var(--mt-in-o); translate: 0 var(--mt-in-y); } 17%, 95% { opacity: 1; translate: 0 0; } 100% { opacity: var(--mt-in-o); translate: 0 0; } } @keyframes mt-in-6 { 0%, 12.5% { opacity: var(--mt-in-o); translate: 0 var(--mt-in-y); } 19.5%, 95% { opacity: 1; translate: 0 0; } 100% { opacity: var(--mt-in-o); translate: 0 0; } } /* The picture is the one element that arrives on its own terms: it uncovers rather than rises, because a photograph revealing itself is the moment a staged entrance is built around. */ @keyframes mt-in-image { 0%, 5% { opacity: var(--mt-in-o); clip-path: inset(0 0 var(--mt-in-clip) 0); } 12%, 95% { opacity: 1; clip-path: inset(0 0 0% 0); } 100% { opacity: var(--mt-in-o); clip-path: inset(0 0 var(--mt-in-clip) 0); } } /* A loop nobody asked for is the definition of a nonessential one. Reduced motion gets the end of the scene instead: the pointer parked over the card it chose, with every state already changed, which is the same information without the movement. */ @media (prefers-reduced-motion: reduce) { /* Pseudo-elements are not valid inside :is() and get dropped from the list rather than failing loudly, so the one that animates is cancelled on its own line. */ .picker-preview-motion .ps-cursor::after, .picker-preview-motion :is( .ps-cursor, .ps-nav, .ps-hero-copy > *, .ps-image, .ps-actions i, .ps-proof-item, .ps-editorial-copy > *, .ps-gallery, .ps-gallery-item, .ps-footer ) { /* The declarations being cancelled are per-element and more specific than any list can be without repeating all ten selectors. A preference the visitor set in their operating system outranks them. */ animation: none !important; } .picker-preview-motion .ps-cursor { translate: 73.8cqw 80.2cqh; } .picker-preview-motion .ps-actions i:first-child { background-color: var(--pvs-ghost); } .picker-preview-motion .ps-actions i:last-child { background-color: var(--pvs-cta); } .picker-preview-motion .ps-gallery-item:nth-child(3) { background-color: var(--pvs-surface); box-shadow: inset 0 0 0 1.5px var(--pvs-cta); translate: 0 var(--mt-lift); } .picker-preview-motion .ps-gallery-item:not(:nth-child(3)) { filter: opacity(var(--mt-dim)); translate: 0 var(--mt-sink); } }