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:
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
152
apps/compliance-portal/src/components/backdropParallax.ts
Normal file
152
apps/compliance-portal/src/components/backdropParallax.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user