mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-18 00:56:30 +03:00
374 lines
14 KiB
CSS
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);
|
|
}
|
|
}
|