Add pointer parallax to logo backdrops

MediaTile and BackdropCard share opposite-pointer blur tracking over
the full card, with Figma-matched opacity/zoom per surface, eased
enter, and a frozen pose on leave.

Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
Émile Ré
2026-07-22 17:04:09 +02:00
parent a0ead155db
commit 87f1ee9081
5 changed files with 203 additions and 8 deletions

View File

@@ -21,6 +21,13 @@
import { Card } from "@probo/ui/src/v2/Card/Card";
import type { ReactNode } from "react";
import {
backdropFrameProps,
blurBackdropClassName,
blurBackdropStyle,
onBackdropPointerLeave,
onBackdropPointerMove,
} from "#/components/backdropParallax";
import { dotPatternStyle } from "#/components/MediaTile/variants";
import { backdropCard } from "./variants";
@@ -41,12 +48,27 @@ interface BackdropCardProps {
// presentational, so it doubles as the layout for its consumers' skeletons.
export function BackdropCard({ media, backdropSrc, children }: BackdropCardProps) {
const slots = backdropCard();
const parallax = backdropSrc != null;
return (
<Card variant="soft" size={3} padding="none">
<div className={slots.header()}>
<Card
variant="soft"
size={3}
padding="none"
onPointerMove={parallax ? onBackdropPointerMove : undefined}
onPointerLeave={parallax ? onBackdropPointerLeave : undefined}
>
<div className={slots.header()} {...(parallax ? backdropFrameProps("card") : undefined)}>
{backdropSrc != null
? <img src={backdropSrc} alt="" aria-hidden className={slots.blurBackdrop()} />
? (
<img
src={backdropSrc}
alt=""
aria-hidden
className={blurBackdropClassName("card")}
style={blurBackdropStyle("card")}
/>
)
: <div className={slots.backdrop()} style={dotPatternStyle} />}
<div className={slots.backdropFade()} />
{media}

View File

@@ -26,7 +26,6 @@ import { tv } from "tailwind-variants/lite";
export const backdropCard = tv({
slots: {
header: "relative flex w-full items-center overflow-hidden p-8 max-md:p-4",
blurBackdrop: "pointer-events-none absolute inset-0 size-full scale-150 object-cover opacity-10 blur-lg",
backdrop: "pointer-events-none absolute inset-0",
backdropFade: "pointer-events-none absolute inset-0 bg-linear-to-b from-sand-1/0 to-sand-1",
body: "flex flex-col gap-2 px-8 pb-8 max-md:px-4 max-md:pb-4",

View File

@@ -22,6 +22,14 @@ import { Card } from "@probo/ui/src/v2/Card/Card";
import type { ReactNode } from "react";
import type { VariantProps } from "tailwind-variants/lite";
import {
backdropFrameProps,
blurBackdropClassName,
blurBackdropStyle,
onBackdropPointerLeave,
onBackdropPointerMove,
} from "#/components/backdropParallax";
import { dotPatternStyle, mediaTile } from "./variants";
export type MediaTileProps = VariantProps<typeof mediaTile> & {
@@ -40,12 +48,27 @@ export type MediaTileProps = VariantProps<typeof mediaTile> & {
// for its skeleton.
export function MediaTile({ media, label, variant = "icon", backdropSrc }: MediaTileProps) {
const slots = mediaTile({ variant });
const parallax = backdropSrc != null;
return (
<Card variant="soft" size={3} padding="none">
<div className={slots.media()}>
<Card
variant="soft"
size={3}
padding="none"
onPointerMove={parallax ? onBackdropPointerMove : undefined}
onPointerLeave={parallax ? onBackdropPointerLeave : undefined}
>
<div className={slots.media()} {...(parallax ? backdropFrameProps("logoTile") : undefined)}>
{backdropSrc != null
? <img src={backdropSrc} alt="" aria-hidden className={slots.blurBackdrop()} />
? (
<img
src={backdropSrc}
alt=""
aria-hidden
className={blurBackdropClassName("logoTile")}
style={blurBackdropStyle("logoTile")}
/>
)
: <div className={slots.backdrop()} style={dotPatternStyle} />}
<div className={slots.backdropFade()} />
<div className={slots.mediaContent()}>{media}</div>

View File

@@ -30,7 +30,6 @@ export const mediaTile = tv({
slots: {
media: "relative flex w-full items-center justify-center overflow-hidden border-b border-sand-a2",
backdrop: "pointer-events-none absolute inset-0",
blurBackdrop: "pointer-events-none absolute inset-0 size-full scale-150 object-cover opacity-10 blur-lg",
backdropFade: "pointer-events-none absolute inset-0 bg-linear-to-b from-sand-1/0 to-sand-1",
mediaContent: "relative z-1 flex size-16 items-center justify-center [&_img]:size-full [&_img]:object-contain",
caption: "flex w-full items-center justify-center px-4 py-3",

View File

@@ -0,0 +1,152 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import type { CSSProperties, PointerEvent } from "react";
// Max backdrop translate (px) at the tracking surface edge. Negated against the
// pointer so the blur moves opposite the cursor, symmetric about center.
const BACKDROP_PARALLAX_PX = 12;
const BACKDROP_EASE = "transform 150ms ease-out";
// Set on the tracking surface while the pointer is over it so the first move
// can ease in (avoids a jump when entering at an edge) while later moves stay
// snappy.
const PARALLAX_ACTIVE = "data-backdrop-parallax";
const BACKDROP_VARIANT = "data-backdrop-variant";
// Per-surface Figma specs. Both use a width-based square centered on the frame
// (top/left 50% + translate -50%). Logo Tile overflows (~inset -42px on a
// ~157px media → scale 1.53); Subprocessor Card fills the header width at 1×.
export type BlurBackdropVariant = "logoTile" | "card";
const blurBackdropByVariant = {
// Figma "Logo Card" Background Image: opacity 6%, blur 8px, inset ≈ -42px.
logoTile: {
className:
"pointer-events-none absolute top-1/2 left-1/2 aspect-square w-full object-cover opacity-[0.06] blur-[8px]",
scale: 1.53,
},
// Figma "Subprocessor Card" Background Image: opacity 10%, blur 14px, width square.
card: {
className:
"pointer-events-none absolute top-1/2 left-1/2 aspect-square w-full object-cover opacity-10 blur-[14px]",
scale: 1,
},
} as const;
export function blurBackdropClassName(variant: BlurBackdropVariant): string {
return blurBackdropByVariant[variant].className;
}
export function blurBackdropTransformRest(variant: BlurBackdropVariant): string {
const { scale } = blurBackdropByVariant[variant];
return scale === 1
? "translate(-50%, -50%)"
: `translate(-50%, -50%) scale(${scale})`;
}
export function blurBackdropStyle(variant: BlurBackdropVariant): CSSProperties {
return { transform: blurBackdropTransformRest(variant) };
}
// Mark the overflow-hidden media/header that owns the blur image, and which
// Figma recipe the parallax transform should preserve (scale).
export function backdropFrameProps(variant: BlurBackdropVariant) {
return {
"data-backdrop-frame": "",
[BACKDROP_VARIANT]: variant,
} as const;
}
function prefersReducedMotion(): boolean {
return typeof window !== "undefined"
&& window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function backdropFrame(scope: HTMLElement): HTMLElement | null {
if (scope.hasAttribute("data-backdrop-frame")) {
return scope;
}
return scope.querySelector<HTMLElement>("[data-backdrop-frame]");
}
function backdropImage(frame: HTMLElement): HTMLElement | null {
return frame.querySelector<HTMLElement>(":scope > img[aria-hidden]");
}
function backdropScale(frame: HTMLElement): number {
const variant = frame.getAttribute(BACKDROP_VARIANT);
if (variant === "logoTile" || variant === "card") {
return blurBackdropByVariant[variant].scale;
}
return 1;
}
function backdropTransform(x: number, y: number, scale: number): string {
const translate = `translate(calc(-50% + ${x}px), calc(-50% + ${y}px))`;
return scale === 1 ? translate : `${translate} scale(${scale})`;
}
// Drive the blur from pointer position over the tracking surface (the whole
// card). X and Y are independent axes against that surface's bounds, so a
// left→right swipe at the top matches one at the bottom, and top→bottom gets
// a full vertical range. Attach listeners to the card; mark the blur's frame
// with backdropFrameProps(variant).
export function onBackdropPointerMove(event: PointerEvent<HTMLElement>) {
if (prefersReducedMotion()) {
return;
}
const surface = event.currentTarget;
const frame = backdropFrame(surface);
if (frame == null) {
return;
}
const image = backdropImage(frame);
if (image == null) {
return;
}
const rect = surface.getBoundingClientRect();
const nx = ((event.clientX - rect.left) / rect.width) * 2 - 1;
const ny = ((event.clientY - rect.top) / rect.height) * 2 - 1;
// Ease from the frozen pose on enter; disable transition afterward so
// tracking stays live.
const entering = !surface.hasAttribute(PARALLAX_ACTIVE);
surface.setAttribute(PARALLAX_ACTIVE, "");
image.style.transition = entering ? BACKDROP_EASE : "none";
image.style.transform = backdropTransform(
-nx * BACKDROP_PARALLAX_PX,
-ny * BACKDROP_PARALLAX_PX,
backdropScale(frame),
);
}
// Leave the blur where it is; only clear the active flag so the next enter can
// ease from that frozen pose instead of jumping.
export function onBackdropPointerLeave(event: PointerEvent<HTMLElement>) {
event.currentTarget.removeAttribute(PARALLAX_ACTIVE);
}