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:
20
README.md
20
README.md
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
html.reveal-ready .reveal {
|
$reveal-ease: cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(18px);
|
|
||||||
transition:
|
|
||||||
opacity 0.55s ease-out,
|
|
||||||
transform 0.55s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
html.reveal-ready .reveal.is-revealed {
|
html.reveal-ready .reveal {
|
||||||
opacity: 1;
|
.landing-section__head,
|
||||||
transform: none;
|
.landing-section__head ~ *,
|
||||||
|
.landing-grid > *,
|
||||||
|
.landing-issues > li {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(24px);
|
||||||
|
transition:
|
||||||
|
opacity 0.6s ease-out,
|
||||||
|
transform 0.7s $reveal-ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
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 {
|
||||||
opacity: 1;
|
.landing-section__head,
|
||||||
transform: none;
|
.landing-section__head ~ *,
|
||||||
transition: none;
|
.landing-grid > *,
|
||||||
|
.landing-issues > li {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user