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

@@ -44,3 +44,88 @@
gap: 0.75rem;
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;
top: 0;
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);
border-bottom: 1px solid var(--pi-color-grey-200, #e4e4ea);
}
@@ -160,3 +164,24 @@
color: var(--pi-color-grey-600, #5b5b6b);
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 type { Copy } from '../core/copy';
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
* rather than shown: "0 proyectos" says less than nothing, and a young site
* 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({
selector: 'app-stat-grid',
@@ -34,6 +59,20 @@ export class StatGrid {
readonly copy = input.required<Copy>();
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(() => {
const raw = this.site()?.stats ?? {};
const labels = this.copy().stats;
@@ -60,7 +99,70 @@ export class StatGrid {
icon: entry.icon,
color: entry.color,
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);
}
}