Pointer depth on the cards, and scroll reveals where the glass allows it - #276
Merged
Conversation
**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
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
CardPointerFxis mounted once and delegates: onepointeroverlistener on the document finds the card withclosest(), 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>insideCardPhotoFrame, never on the card. The card carries the twobackdrop-filterpanels, 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.The highlight has no blend mode, and that was the whole performance story
mix-blend-mode: overlaylooked richer and cost the entire feature. Sweeping one card, median frame time: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:
mouse.moveputs 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.Section reveals
.pk-revealis a scroll-driven CSS animation (animation-timeline: view()), deliberately not the existing<Reveal>component.<Reveal>rendersopacity-0and 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 fillsbackwards, 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-filterunderneath flattens for the length of the reveal. That rules out the ML-stats and live-activity sections — both nestGlassCards — and card grids, which get the pointer effects instead. My first pass had all four sections wired; a browser check found 11backdrop-filterelements inside a reveal and two of them came back out.Checks
pnpm build— green;/[locale]still reports 1h in the revalidate columnpnpm release:check— lint, format, translations, client-message namespacesblur(16px)through a hover, the photo drifts ±4 px per axis and returns, zerobackdrop-filterelements inside a reveal, and revealed sections showtransform: none/opacity: 1once past the range🤖 Generated with Claude Code
https://claude.ai/code/session_012y3NUKjqx4ZxrCseyCDXeQ
Generated by Claude Code