Files
pbakaus_impeccable/picker/styles/screens/motion.css
T
2026-09-01 10:02:39 +05:00

374 lines
14 KiB
CSS

/* ============================================================
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);
}
}