Skip to content

Pointer depth on the cards, and scroll reveals where the glass allows it - #276

Merged
PArns merged 2 commits into
mainfrom
claude/hero-redesign-h5e181
Aug 7, 2026
Merged

Pointer depth on the cards, and scroll reveals where the glass allows it#276
PArns merged 2 commits into
mainfrom
claude/hero-redesign-h5e181

Conversation

@PArns

@PArns PArns commented Aug 7, 2026

Copy link
Copy Markdown
Owner

Two things: pointer depth on every card, and a scroll reveal on the sections that can take one.

Pointer depth on the cards

The photo drifts a few pixels against the pointer while the frosted panels hold still, and a soft highlight follows the pointer across the card. The drift is the idea — moving the picture while the glass stays put is what makes the panels read as floating above it rather than printed on it, and it is the one part CSS cannot do, because it needs the pointer's position.

CardPointerFx is mounted once and delegates: one pointerover listener on the document finds the card with closest(), and the per-frame work is bound only while a card is actually hovered. A grid of twenty cards costs the same as one. It skips itself entirely for reduced motion and for coarse pointers.

The drift goes on the <img> inside CardPhotoFrame, never on the card. The card carries the two backdrop-filter panels, and a transform on it would make it a backdrop root for as long as the pointer was inside — the glass would go flat exactly while somebody is looking at it.

Worth recording, because I went in expecting the opposite: Tailwind's existing hover:-translate-y-1 on the card is safe. v4 compiles it to the standalone translate property, and Chromium does not treat that as a backdrop root. Measured through a hover, the panel's backdrop detail holds at 26.96 → 26.84.

The highlight has no blend mode, and that was the whole performance story

mix-blend-mode: overlay looked richer and cost the entire feature. Sweeping one card, median frame time:

1× CPU 4× CPU
drift + highlight, blended 43.6 ms 44.3 ms
drift + highlight, no blend 38.3 ms 35.4 ms
drift only 38.9 ms 34.0 ms
neither (baseline) 39.4 ms 36.2 ms

Without it the whole effect sits inside the baseline's noise. A blended layer has to re-read what it sits on — here, two frosted panels — every frame; a plain translucent gradient just composites.

Two notes for whoever tunes this next:

  • Measure from inside the page. Driving the pointer with Playwright's mouse.move puts a CDP round trip between frames. It reported a 20 ms regression that does not exist, and I nearly cut the feature over it before re-measuring with synthetic events dispatched in a rAF loop.
  • Caching the card's rect instead of reading it per frame turned out not to matter here. It stays in anyway — reading layout inside a rAF that then writes styles is a trap regardless of whether this particular page notices.

Section reveals

.pk-reveal is a scroll-driven CSS animation (animation-timeline: view()), deliberately not the existing <Reveal> component. <Reveal> renders opacity-0 and needs an IntersectionObserver to bring it back, so with JavaScript blocked its content never appears — the one thing this codebase does not do with content. A view timeline degrades the other way: no support means the section simply shows, unanimated. It fills backwards, so nothing lingers afterwards.

It only goes on sections with no glass in them. During the entry range the transform is real, so any backdrop-filter underneath flattens for the length of the reveal. That rules out the ML-stats and live-activity sections — both nest GlassCards — and card grids, which get the pointer effects instead. My first pass had all four sections wired; a browser check found 11 backdrop-filter elements inside a reveal and two of them came back out.

Checks

  • pnpm build — green; /[locale] still reports 1h in the revalidate column
  • pnpm release:check — lint, format, translations, client-message namespaces
  • Verified in the browser: the card never takes a transform, the panel keeps blur(16px) through a hover, the photo drifts ±4 px per axis and returns, zero backdrop-filter elements inside a reveal, and revealed sections show transform: none / opacity: 1 once past the range

🤖 Generated with Claude Code

https://claude.ai/code/session_012y3NUKjqx4ZxrCseyCDXeQ


Generated by Claude Code

**Pointer depth on every card.** The photo drifts a few pixels against the
pointer while the frosted panels hold still, and a soft highlight follows the
pointer across the card. The drift is the idea: moving the picture while the
glass stays put is what makes the panels read as floating above it rather than
printed on it, and it is the one part CSS cannot do, because it needs the
pointer's position.

The drift goes on the `<img>` inside CardPhotoFrame, never on the card. The card
carries the two backdrop-filter panels, and a transform on it would make it a
backdrop root for as long as the pointer was inside — the glass would go flat
exactly while somebody is looking at it.

Worth recording, because I went in expecting the opposite: Tailwind's existing
`hover:-translate-y-1` on the card is safe. v4 compiles it to the standalone
`translate` property, and Chromium does not treat that as a backdrop root. The
panel's backdrop detail holds at 26.96 -> 26.84 through a hover.

**The highlight has no blend mode, and that was the whole performance story.**
`mix-blend-mode: overlay` looked richer and cost the entire feature:

| sweeping one card, median frame | 1x CPU | 4x CPU |
| --- | --- | --- |
| drift + highlight, blended | 43.6 ms | 44.3 ms |
| drift + highlight, no blend | 38.3 ms | 35.4 ms |
| drift only | 38.9 ms | 34.0 ms |
| neither (baseline) | 39.4 ms | 36.2 ms |

Without it the whole effect sits inside the baseline's noise. A blended layer has
to re-read what it sits on — here two frosted panels — every frame; a plain
translucent gradient just composites.

Two measurement notes for whoever tunes this next. Driving the pointer with
Playwright's `mouse.move` puts a CDP round trip between frames: it reported a
20 ms regression that does not exist, and I nearly cut the feature over it.
Measure from inside the page. And caching the card's rect instead of reading it
per frame turned out not to matter here, but it stays in, because reading layout
inside a rAF that then writes styles is a trap regardless.

**Section reveals**, CSS only — `animation-timeline: view()`, not the existing
`<Reveal>`. That component renders `opacity-0` and needs an IntersectionObserver
to bring it back, so with JavaScript blocked its content never appears. A view
timeline degrades the other way: no support means the section simply shows.

Only sections with no glass in them get it. During the entry range the transform
is real, so any backdrop-filter underneath flattens for the length of the reveal
— which rules out the ML-stats and live-activity sections, both of which nest
GlassCards. Verified in the browser: zero backdrop-filter elements inside a
reveal, and nothing lingers afterwards (transform none, opacity 1, past the
range).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012y3NUKjqx4ZxrCseyCDXeQ
@vercel

vercel Bot commented Aug 7, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
park.fan Ready Ready Preview Aug 7, 2026 9:32am

Request Review

Three things, two of them mine.

**The snap back.** After the pointer left, the photo glided home and then jumped
to wherever it had been — and by a different amount depending on which edge you
left by. `quickTo` keeps one persistent tween per property and holds its last
target, so the `gsap.to(img, {x: 0, y: 0})` in the release path never replaced
it: both wrote every frame, and the moment the return finished the quickTo
re-asserted the old offset. Traced frame by frame, a clean glide to 0,0 and then
a 5.95 px step back to 4.48,3.92 on the twelfth frame. The way home goes through
the same setters now. Leaving from all four sides settles at exactly 0,0.

**The reflection showing through.** The drift was a flat 7 px, but the headroom
the photo's own zoom buys is `(scale - 1) / 2` per axis: 12.1 px across and only
6.0 px down. So it slid a pixel past its own top edge and exposed the bleed layer
underneath, reflection included. The limit is derived per axis from the measured
box now (85 % of it), which stays right at any card size and stays right if
somebody retunes the scale. Worth stating plainly: the reflection itself is fine
— with the effect disabled the strip is clean.

**And it only ran on the homepage.** The controller moved to the locale layout,
so the cards behave the same everywhere: verified on the homepage (18 cards), a
country page (9), a park's attraction grid (22) and the blog index (11).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012y3NUKjqx4ZxrCseyCDXeQ
@PArns
PArns marked this pull request as ready for review August 7, 2026 09:40
@PArns
PArns merged commit 1a80e1b into main Aug 7, 2026
3 checks passed
@PArns
PArns deleted the claude/hero-redesign-h5e181 branch August 7, 2026 09:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants