Bring the landing to life as it scrolls

- Each section assembles itself on arrival: heading first, then what it
  holds, and a grid's cards one after another — instead of the whole
  section fading in as one block.
- The hero enters on load, piece by piece, in CSS, so it plays with or
  without JavaScript; the stats come last and count up from zero.
- The hero's lights drift and fade as it scrolls away, and the header
  gains a shadow once the page moves — scroll-driven animations, no
  listeners; browsers without them simply do not animate.

The count only runs if the page hydrates before the stats appear at
0.8s: after that the figure has been read, and dropping it to zero to
count again would be a flicker. Server-rendered HTML carries the real
numbers and nothing hidden, and prefers-reduced-motion turns all of it
off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
pigui
2026-09-29 16:21:48 +02:00
parent fea60a168c
commit 240ad149e7
5 changed files with 287 additions and 21 deletions

View File

@@ -424,11 +424,21 @@ Two pieces, both of which have to survive a page that renders on the server:
so the arrows have to be asked for, and the controls only appear at all once so the arrows have to be asked for, and the controls only appear at all once
there are more items than fit — with two testimonials it degrades to a plain there are more items than fit — with two testimonials it degrades to a plain
row, which is the right answer. row, which is the right answer.
- **`ui/reveal.ts`** fades each section in as it arrives. The hidden state is - **`ui/reveal.ts`** assembles each section as it arrives: its heading first,
*not* in the server-rendered HTML: it only applies under `html.reveal-ready`, then what it holds, and a grid's cards one after another (up to eight, then
a class the directive adds at runtime, so a visitor without JavaScript reads a together). The hidden state is *not* in the server-rendered HTML: it only
full page instead of a blank column. `prefers-reduced-motion` skips the applies under `html.reveal-ready`, a class the directive adds at runtime, so
observer entirely. a visitor without JavaScript reads a full page instead of a blank column.
- **The hero enters on load**, in CSS (`hero.scss`), so it plays with or
without JavaScript: eyebrow, title, lead, buttons, and the stats last, at
0.8s. The stats count up from zero — but only if the page hydrates before
then (`COUNT_UP_BEFORE_MS` in `stat-grid.ts`). Later than that the number has
already been read, and dropping it to zero to count again would be a flicker.
- **Scroll-driven, no JavaScript**: the hero's lights drift and fade as it
scrolls away, and the header gains a shadow once the page moves
(`animation-timeline: scroll()`). Browsers without it simply do not animate.
`prefers-reduced-motion` turns every one of these off.
`PiThemeService` owns the shell class, pinned to `alternative` in `PiThemeService` owns the shell class, pinned to `alternative` in
`app.config.ts` — the library's marketing look: pill buttons with a gradient `app.config.ts` — the library's marketing look: pill buttons with a gradient

View File

@@ -44,3 +44,88 @@
gap: 0.75rem; gap: 0.75rem;
margin-top: 2.25rem; margin-top: 2.25rem;
} }
// ---------------------------------------------------------------------------
// Entrance.
//
// The first thing on the page, so it plays on load rather than on scroll, and
// in CSS rather than from the directive: it runs whether or not JavaScript
// ever does, and `both` keeps each piece hidden until its turn and shown after.
//
// The stats come last, at 0.8s. `StatGrid` counts its numbers up from zero
// only if it hydrates before then — see `COUNT_UP_BEFORE_MS` there.
// ---------------------------------------------------------------------------
@keyframes hero-in {
from {
opacity: 0;
transform: translateY(22px);
}
}
@media (prefers-reduced-motion: no-preference) {
.landing-hero .landing-eyebrow,
.landing-hero__title,
.landing-hero__lead,
.landing-hero__actions,
app-stat-grid {
animation: hero-in 0.8s cubic-bezier(0.22, 1, 0.36, 1) both;
}
.landing-hero__title {
animation-delay: 0.08s;
}
.landing-hero__lead {
animation-delay: 0.2s;
}
.landing-hero__actions {
animation-delay: 0.32s;
}
app-stat-grid {
display: block;
animation-delay: 0.8s;
}
}
// ---------------------------------------------------------------------------
// The lights drift and fade as the hero scrolls away, tied to the scroll
// itself rather than to a listener. Where the browser has no scroll-driven
// animations the lights simply stay put.
// ---------------------------------------------------------------------------
@keyframes hero-light-a {
to {
opacity: 0.15;
transform: translateX(-58%) translateY(9rem) scale(1.15);
}
}
@keyframes hero-light-b {
to {
opacity: 0.1;
transform: translate(-6rem, 5rem);
}
}
@supports (animation-timeline: scroll()) {
@media (prefers-reduced-motion: no-preference) {
.landing-hero::before,
.landing-hero::after {
animation-timeline: scroll(root);
animation-range: 0 90vh;
animation-fill-mode: both;
animation-timing-function: linear;
}
.landing-hero::before {
animation-name: hero-light-a;
}
.landing-hero::after {
animation-name: hero-light-b;
}
}
}

View File

@@ -6,7 +6,11 @@
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 50; z-index: 50;
background-color: color-mix(in srgb, var(--pi-color-white, #fff) 88%, transparent); background-color: color-mix(
in srgb,
var(--pi-color-white, #fff) 88%,
transparent
);
backdrop-filter: blur(12px); backdrop-filter: blur(12px);
border-bottom: 1px solid var(--pi-color-grey-200, #e4e4ea); border-bottom: 1px solid var(--pi-color-grey-200, #e4e4ea);
} }
@@ -160,3 +164,24 @@
color: var(--pi-color-grey-600, #5b5b6b); color: var(--pi-color-grey-600, #5b5b6b);
cursor: pointer; cursor: pointer;
} }
// A shadow under the header once the page moves, so it reads as floating over
// the content rather than as part of it. Tied to the scroll in CSS; without
// scroll-driven animations the border alone does the job, as before.
@keyframes header-lift {
to {
border-bottom-color: transparent;
box-shadow: 0 10px 30px -18px
color-mix(in srgb, var(--pi-color-primary) 45%, transparent);
}
}
@supports (animation-timeline: scroll()) {
@media (prefers-reduced-motion: no-preference) {
.landing-header {
animation: header-lift linear both;
animation-timeline: scroll(root);
animation-range: 0 5rem;
}
}
}

View File

@@ -1,13 +1,38 @@
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core'; import { DOCUMENT, isPlatformBrowser } from '@angular/common';
import {
afterNextRender,
ChangeDetectionStrategy,
Component,
computed,
DestroyRef,
ElementRef,
inject,
input,
PLATFORM_ID,
signal,
} from '@angular/core';
import { PiStatCardComponent } from '@pi/ui'; import { PiStatCardComponent } from '@pi/ui';
import type { Copy } from '../core/copy'; import type { Copy } from '../core/copy';
import type { Locale, SitePayload } from '../core/api'; import type { Locale, SitePayload } from '../core/api';
/**
* The hero's stats appear 0.8s after load (`hero.scss`). Counting up is only
* worth it if it can start from zero before anyone has read the number: after
* that, dropping a figure already on screen to 0 is a flicker, not an effect.
*/
const COUNT_UP_BEFORE_MS = 800;
const COUNT_UP_DURATION_MS = 1400;
/** /**
* The headline numbers from `/site`, as `pi-stat-card`. Zeroes are dropped * The headline numbers from `/site`, as `pi-stat-card`. Zeroes are dropped
* rather than shown: "0 proyectos" says less than nothing, and a young site * rather than shown: "0 proyectos" says less than nothing, and a young site
* has plenty of them. * has plenty of them.
*
* The numbers count up from zero as they come into view. The server renders
* the real figures, so without JavaScript — or with reduced motion — they are
* simply there.
*/ */
@Component({ @Component({
selector: 'app-stat-grid', selector: 'app-stat-grid',
@@ -34,6 +59,20 @@ export class StatGrid {
readonly copy = input.required<Copy>(); readonly copy = input.required<Copy>();
readonly locale = input.required<Locale>(); readonly locale = input.required<Locale>();
private readonly host = inject(ElementRef<HTMLElement>)
.nativeElement as HTMLElement;
private readonly document = inject(DOCUMENT);
private readonly destroyRef = inject(DestroyRef);
/** 0 → 1 while counting; 1 on the server and whenever there is no animation. */
private readonly progress = signal(1);
constructor() {
if (isPlatformBrowser(inject(PLATFORM_ID))) {
afterNextRender(() => this.countUpWhenSeen());
}
}
protected readonly stats = computed(() => { protected readonly stats = computed(() => {
const raw = this.site()?.stats ?? {}; const raw = this.site()?.stats ?? {};
const labels = this.copy().stats; const labels = this.copy().stats;
@@ -60,7 +99,70 @@ export class StatGrid {
icon: entry.icon, icon: entry.icon,
color: entry.color, color: entry.color,
label: labels[entry.key], label: labels[entry.key],
value: format.format(raw[entry.key] ?? 0), value: format.format(
Math.round((raw[entry.key] ?? 0) * this.progress()),
),
})); }));
}); });
private countUpWhenSeen(): void {
const view = this.document.defaultView;
if (
!view ||
typeof IntersectionObserver === 'undefined' ||
view.matchMedia('(prefers-reduced-motion: reduce)').matches
) {
return;
}
const onScreen = this.host.getBoundingClientRect().top < view.innerHeight;
if (onScreen) {
// In the hero at the top of the page: start with its entrance, or not
// at all if the number has already been on screen.
const wait = COUNT_UP_BEFORE_MS - view.performance.now();
if (wait > 0) {
this.progress.set(0);
view.setTimeout(() => this.countUp(view), wait);
}
return;
}
// Further down — a reload that restored the scroll position: count when
// it arrives.
this.progress.set(0);
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
observer.disconnect();
this.countUp(view);
}
},
{ threshold: 0.4 },
);
observer.observe(this.host);
this.destroyRef.onDestroy(() => observer.disconnect());
}
private countUp(view: Window): void {
const started = view.performance.now();
const tick = (now: number) => {
const elapsed = Math.min(1, (now - started) / COUNT_UP_DURATION_MS);
// Ease out: fast at first, settling on the real figure.
this.progress.set(1 - Math.pow(1 - elapsed, 3));
if (elapsed < 1) {
view.requestAnimationFrame(tick);
}
};
view.requestAnimationFrame(tick);
}
} }

View File

@@ -472,25 +472,69 @@ html.dark-mode .pi-shell {
// so a visitor without JavaScript — or one whose hydration failed — reads a // so a visitor without JavaScript — or one whose hydration failed — reads a
// fully visible page instead of a blank column. The media query is a second // fully visible page instead of a blank column. The media query is a second
// belt: the directive already skips the observer when motion is reduced. // belt: the directive already skips the observer when motion is reduced.
//
// A section does not fade as one block: its heading arrives first, then what
// it holds, and the cards of a grid one after another — so reading down the
// page, each section assembles itself as you reach it.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
$reveal-ease: cubic-bezier(0.22, 1, 0.36, 1);
html.reveal-ready .reveal { html.reveal-ready .reveal {
.landing-section__head,
.landing-section__head ~ *,
.landing-grid > *,
.landing-issues > li {
opacity: 0; opacity: 0;
transform: translateY(18px); transform: translateY(24px);
transition: transition:
opacity 0.55s ease-out, opacity 0.6s ease-out,
transform 0.55s ease-out; transform 0.7s $reveal-ease;
} }
html.reveal-ready .reveal.is-revealed { // Cards also grow into place, which reads as arriving rather than sliding.
.landing-grid > * {
transform: translateY(28px) scale(0.97);
}
&.is-revealed {
.landing-section__head,
.landing-section__head ~ *,
.landing-grid > *,
.landing-issues > li {
opacity: 1; opacity: 1;
transform: none; transform: none;
} }
.landing-section__head ~ * {
transition-delay: 0.12s;
}
// One after another; past the eighth they arrive together, so a long grid
// does not keep the reader waiting for its last row.
@for $i from 1 through 8 {
.landing-grid > :nth-child(#{$i}),
.landing-issues > li:nth-child(#{$i}) {
transition-delay: #{0.15 + ($i - 1) * 0.08}s;
}
}
.landing-grid > :nth-child(n + 9),
.landing-issues > li:nth-child(n + 9) {
transition-delay: 0.8s;
}
}
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
html.reveal-ready .reveal { html.reveal-ready .reveal {
.landing-section__head,
.landing-section__head ~ *,
.landing-grid > *,
.landing-issues > li {
opacity: 1; opacity: 1;
transform: none; transform: none;
transition: none; transition: none;
} }
} }
}