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:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user