Homepage 3 cards finished (#19732)

& various fixes
This commit is contained in:
Thomas des Francs
2026-04-16 08:42:25 +02:00
committed by GitHub
parent cddc47b61f
commit da8fe7f6f7
36 changed files with 4910 additions and 837 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.8 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<rect width="16" height="16" rx="2" fill="#000" />
<path
d="M2.147 9.383c-.027-.114.109-.186.192-.103L6.72 13.66c.083.083.011.219-.103.192a6.015 6.015 0 0 1-4.47-4.47ZM2 7.627a.119.119 0 0 0 .035.091l6.247 6.247a.119.119 0 0 0 .091.035c.285-.018.564-.055.836-.111a.117.117 0 0 0 .057-.198L2.31 6.734a.117.117 0 0 0-.198.057 6.007 6.007 0 0 0-.11.836ZM2.505 5.565a.119.119 0 0 0 .025.132l7.773 7.773a.118.118 0 0 0 .132.025c.215-.096.422-.203.623-.322a.118.118 0 0 0 .022-.185L3.012 4.92a.118.118 0 0 0-.185.022c-.119.2-.226.408-.322.623ZM3.519 4.169a.118.118 0 0 1-.005-.163 6.006 6.006 0 1 1 8.48 8.48.118.118 0 0 1-.163-.005L3.52 4.169Z"
fill="#FFF"
/>
</svg>

After

Width:  |  Height:  |  Size: 760 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

@@ -0,0 +1,13 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="40"
height="40"
viewBox="0 0 40 40"
fill="none"
>
<rect width="40" height="40" rx="8" fill="#000000" />
<path
d="M6.822 14.174c0-2.435 1.996-4.411 4.456-4.411h8.574c.125 0 .241.075.293.19a.31.31 0 0 1-.056.344l-1.88 2.023c-.326.35-.787.552-1.27.552H11.3c-.738 0-1.338.594-1.338 1.325v3.336a.78.78 0 0 1-.783.777H7.61a.78.78 0 0 1-.783-.777v-3.36zM33.5 25.553c0 2.434-1.996 4.411-4.456 4.411h-3.642c-2.46 0-4.454-1.977-4.454-4.411v-6.315c0-.43.16-.842.456-1.16l2.124-2.285a.33.33 0 0 1 .355-.081.32.32 0 0 1 .205.295v9.527c0 .73.598 1.322 1.337 1.322h3.6a1.33 1.33 0 0 0 1.337-1.322V14.197c0-.73-.599-1.325-1.337-1.325H24.84c-.481 0-.938.201-1.265.547L11.088 26.856h7.503a.78.78 0 0 1 .784.778v1.552a.78.78 0 0 1-.784.778H8.481a1.655 1.655 0 0 1-1.662-1.644v-.824c0-.412.156-.809.44-1.114l13.999-15.06a4.9 4.9 0 0 1 3.594-1.56h4.189c2.46 0 4.454 1.977 4.454 4.412v11.379z"
fill="#FFFFFF"
/>
</svg>

After

Width:  |  Height:  |  Size: 968 B

@@ -18,6 +18,8 @@ export const THREE_CARDS_FEATURE_DATA: ThreeCardsFeatureCardsDataType = {
body: {
text: "Twenty makes it simple. It's clean, intuitive, and built to feel like Notion.",
},
backgroundImageSrc:
'/images/home/three-cards-feature/familiar-interface-gradient.webp',
icon: 'users-group',
illustration: 'familiar-interface',
},
@@ -26,6 +28,8 @@ export const THREE_CARDS_FEATURE_DATA: ThreeCardsFeatureCardsDataType = {
body: {
text: 'Everything updates in real time, with AI chat always ready to help you move faster.',
},
backgroundImageSrc:
'/images/home/three-cards-feature/live-data-gradient.webp',
icon: 'live-data',
illustration: 'live-data',
},
@@ -34,6 +38,8 @@ export const THREE_CARDS_FEATURE_DATA: ThreeCardsFeatureCardsDataType = {
body: {
text: 'Smart patterns, shortcuts, and layouts make everyday tasks faster and easier to execute.',
},
backgroundImageSrc:
'/images/home/three-cards-feature/fast-path-gradient.webp',
icon: 'fast-path',
illustration: 'fast-path',
},
@@ -265,8 +265,9 @@ const IMAGE_HOVER_FADE_IN = 18;
const IMAGE_HOVER_FADE_OUT = 7;
const MAX_PREVIEW_PIXEL_RATIO = 2;
const CanvasMount = styled.div<{ $background: string }>`
background: ${(props) => props.$background};
const CanvasMount = styled.div<{ $background: string; $transparent: boolean }>`
background: ${(props) =>
props.$transparent ? 'transparent' : props.$background};
display: block;
height: 100%;
min-width: 0;
@@ -1618,10 +1619,10 @@ export function HalftoneCanvas({
-interaction.pointerVelocityY * logicalHeight,
);
halftoneMaterial.uniforms.dragOffset.value.set(0, 0);
halftoneMaterial.uniforms.hoverHalftoneActive.value =
activeSettings.animation.hoverHalftoneEnabled
? interaction.hoverStrength
: 0;
halftoneMaterial.uniforms.hoverHalftoneActive.value = activeSettings
.animation.hoverHalftoneEnabled
? interaction.hoverStrength
: 0;
halftoneMaterial.uniforms.hoverHalftonePowerShift.value =
activeSettings.animation.hoverHalftoneEnabled
? activeSettings.animation.hoverHalftonePowerShift
@@ -1632,11 +1633,11 @@ export function HalftoneCanvas({
activeSettings.animation.hoverHalftoneEnabled
? activeSettings.animation.hoverHalftoneWidthShift
: 0;
halftoneMaterial.uniforms.hoverLightStrength.value =
activeSettings.animation.hoverLightEnabled
? activeSettings.animation.hoverLightIntensity *
interaction.hoverStrength
: 0;
halftoneMaterial.uniforms.hoverLightStrength.value = activeSettings
.animation.hoverLightEnabled
? activeSettings.animation.hoverLightIntensity *
interaction.hoverStrength
: 0;
halftoneMaterial.uniforms.hoverLightRadius.value =
activeSettings.animation.hoverLightRadius;
halftoneMaterial.uniforms.hoverFlowStrength.value = 0;
@@ -2031,6 +2032,7 @@ export function HalftoneCanvas({
return (
<CanvasMount
$background={settings.background.color}
$transparent={settings.background.transparent}
aria-hidden
ref={mountReference}
/>
@@ -225,12 +225,12 @@ export const SALESFORCE_DATA: SalesforceDataType = {
productIconAlt: 'Retro help document icon',
productIconSrc: '/images/pricing/salesforce/help-icon.webp',
priceSuffix: ' / seat / month - billed yearly',
productTitle: 'Salesforce Pro',
productTitle: 'Salesfarce Pro',
secondaryCtaNote: 'More options available!',
secondaryCtaHref:
'https://www.salesforce.com/en-us/wp-content/uploads/sites/4/documents/pricing/all-add-ons.pdf',
secondaryCtaLabel: 'Check more add-ons',
totalPriceLabel: 'total per month with fixed cost',
windowTitle: 'Salesforce Pro pricing',
windowTitle: 'Salesfarce Add-on Center',
},
};
@@ -1,5 +1,17 @@
'use client';
import { HalftoneCanvas } from '@/app/halftone/_components/HalftoneCanvas';
import {
DEFAULT_GLASS_ANIMATION_SETTINGS,
DEFAULT_GLASS_BACKGROUND_SETTINGS,
DEFAULT_GLASS_MATERIAL_SETTINGS,
DEFAULT_SHAPE_HALFTONE_SETTINGS,
DEFAULT_SOLID_ANIMATION_SETTINGS,
DEFAULT_SOLID_BACKGROUND_SETTINGS,
DEFAULT_SOLID_MATERIAL_SETTINGS,
type HalftoneMaterialSurface,
type HalftoneStudioSettings,
} from '@/app/halftone/_lib/state';
import { styled } from '@linaria/react';
import { useEffect, useRef, useState } from 'react';
import * as THREE from 'three';
@@ -17,6 +29,7 @@ const MIN_FOOTPRINT_SCALE = 0.001;
type HalftoneRotateAxis = 'x' | 'y' | 'z' | 'xy' | '-x' | '-y' | '-z' | '-xy';
type HalftoneRotatePreset = 'axis' | 'lissajous' | 'orbit' | 'tumble';
type HelpedHalftoneRenderer = 'legacy' | 'studio';
type ViewRect = {
x: number;
@@ -47,13 +60,20 @@ export type HelpedHalftoneSettings = {
material: {
roughness: number;
metalness: number;
surface?: HalftoneMaterialSurface;
color?: string;
thickness?: number;
refraction?: number;
environmentPower?: number;
};
halftone: {
enabled: boolean;
scale: number;
power: number;
width: number;
imageContrast?: number;
dashColor: string;
hoverDashColor?: string;
};
animation: {
autoRotateEnabled: boolean;
@@ -91,6 +111,10 @@ export type HelpedHalftoneSettings = {
springDamping: number;
springReturnEnabled: boolean;
springStrength: number;
hoverHalftoneEnabled?: boolean;
hoverHalftonePowerShift?: number;
hoverHalftoneRadius?: number;
hoverHalftoneWidthShift?: number;
hoverLightIntensity: number;
hoverLightRadius: number;
dragFlowDecay: number;
@@ -533,10 +557,7 @@ function getFootprintScaleFromRects(
return 1;
}
return Math.max(
Math.sqrt(currentArea / referenceArea),
MIN_FOOTPRINT_SCALE,
);
return Math.max(Math.sqrt(currentArea / referenceArea), MIN_FOOTPRINT_SCALE);
}
function projectBox3ToViewport({
@@ -552,11 +573,7 @@ function projectBox3ToViewport({
viewportHeight: number;
viewportWidth: number;
}) {
if (
localBounds.isEmpty() ||
viewportWidth <= 0 ||
viewportHeight <= 0
) {
if (localBounds.isEmpty() || viewportWidth <= 0 || viewportHeight <= 0) {
return null;
}
@@ -728,6 +745,57 @@ const StyledVisualMount = styled.div`
width: 100%;
`;
const NOOP = () => {};
function getStudioMaterialSurface(
settings: HelpedHalftoneSettings,
): HalftoneMaterialSurface {
return settings.material.surface === 'glass' ? 'glass' : 'solid';
}
function createStudioSettings(
settings: HelpedHalftoneSettings,
): HalftoneStudioSettings {
const surface = getStudioMaterialSurface(settings);
const defaultMaterial =
surface === 'glass'
? DEFAULT_GLASS_MATERIAL_SETTINGS
: DEFAULT_SOLID_MATERIAL_SETTINGS;
const defaultAnimation =
surface === 'glass'
? DEFAULT_GLASS_ANIMATION_SETTINGS
: DEFAULT_SOLID_ANIMATION_SETTINGS;
const defaultBackground =
surface === 'glass'
? DEFAULT_GLASS_BACKGROUND_SETTINGS
: DEFAULT_SOLID_BACKGROUND_SETTINGS;
return {
sourceMode: 'shape',
shapeKey: 'helped',
lighting: { ...settings.lighting },
material: {
...defaultMaterial,
...settings.material,
surface,
},
halftone: {
...DEFAULT_SHAPE_HALFTONE_SETTINGS,
...settings.halftone,
hoverDashColor:
settings.halftone.hoverDashColor ?? settings.halftone.dashColor,
imageContrast:
settings.halftone.imageContrast ??
DEFAULT_SHAPE_HALFTONE_SETTINGS.imageContrast,
},
background: defaultBackground,
animation: {
...defaultAnimation,
...settings.animation,
},
};
}
type HelpedHalftoneCanvasProps = {
geometry: THREE.BufferGeometry;
initialPose: HelpedHalftonePose;
@@ -770,7 +838,9 @@ function HelpedHalftoneCanvas({
renderer.setSize(getVirtualWidth(), getVirtualHeight(), false);
const canvas = renderer.domElement;
canvas.style.cursor = settings.animation.followDragEnabled ? 'grab' : 'default';
canvas.style.cursor = settings.animation.followDragEnabled
? 'grab'
: 'default';
canvas.style.display = 'block';
canvas.style.height = '100%';
canvas.style.touchAction = 'none';
@@ -855,7 +925,9 @@ function HelpedHalftoneCanvas({
dashColor: { value: new THREE.Color(settings.halftone.dashColor) },
time: { value: 0 },
waveAmount: {
value: settings.animation.waveEnabled ? settings.animation.waveAmount : 0,
value: settings.animation.waveEnabled
? settings.animation.waveAmount
: 0,
},
waveSpeed: { value: settings.animation.waveSpeed },
footprintScale: { value: 1.0 },
@@ -1073,8 +1145,7 @@ function HelpedHalftoneCanvas({
const floatPhase = elapsedTime * settings.animation.floatSpeed;
const driftAmount = (settings.animation.driftAmount * Math.PI) / 180;
meshOffsetY +=
Math.sin(floatPhase) * settings.animation.floatAmplitude;
meshOffsetY += Math.sin(floatPhase) * settings.animation.floatAmplitude;
baseRotationX += Math.sin(floatPhase * 0.72) * driftAmount * 0.45;
baseRotationZ += Math.cos(floatPhase * 0.93) * driftAmount * 0.3;
}
@@ -1089,13 +1160,15 @@ function HelpedHalftoneCanvas({
if (rotateEnabled) {
interaction.rotateElapsed += delta;
const rotateProgress = settings.animation.rotatePingPong
? Math.sin(interaction.rotateElapsed * settings.animation.rotateSpeed) *
Math.PI
? Math.sin(
interaction.rotateElapsed * settings.animation.rotateSpeed,
) * Math.PI
: interaction.rotateElapsed * settings.animation.rotateSpeed;
if (settings.animation.rotatePreset === 'axis') {
const axisDirection =
settings.animation.rotateAxis.startsWith('-') ? -1 : 1;
const axisDirection = settings.animation.rotateAxis.startsWith('-')
? -1
: 1;
const axisProgress = rotateProgress * axisDirection;
if (
@@ -1256,8 +1329,7 @@ function HelpedHalftoneCanvas({
const orbitPitch = centeredY * cameraRange * 0.7;
const horizontalRadius = Math.cos(orbitPitch) * baseCameraDistance;
const targetCameraX = Math.sin(orbitYaw) * horizontalRadius;
const targetCameraY =
Math.sin(orbitPitch) * baseCameraDistance * 0.85;
const targetCameraY = Math.sin(orbitPitch) * baseCameraDistance * 0.85;
const targetCameraZ = Math.cos(orbitYaw) * horizontalRadius;
camera.position.x += (targetCameraX - camera.position.x) * cameraEase;
@@ -1266,8 +1338,7 @@ function HelpedHalftoneCanvas({
} else {
camera.position.x += (0 - camera.position.x) * 0.12;
camera.position.y += (0 - camera.position.y) * 0.12;
camera.position.z +=
(baseCameraDistance - camera.position.z) * 0.12;
camera.position.z += (baseCameraDistance - camera.position.z) * 0.12;
}
lookAtTarget.set(0, meshOffsetY * 0.2, 0);
@@ -1329,11 +1400,37 @@ function HelpedHalftoneCanvas({
return <StyledVisualMount aria-hidden ref={mountReference} />;
}
function StudioHelpedHalftoneCanvas({
geometry,
initialPose,
previewDistance,
settings,
}: HelpedHalftoneCanvasProps) {
useEffect(() => {
return () => {
geometry.dispose();
};
}, [geometry]);
return (
<HalftoneCanvas
geometry={geometry}
imageElement={null}
initialPose={initialPose}
onFirstInteraction={NOOP}
onPoseChange={NOOP}
previewDistance={previewDistance}
settings={createStudioSettings(settings)}
/>
);
}
type HelpedHalftoneModelProps = {
initialPose: HelpedHalftonePose;
label: string;
modelUrl: string;
previewDistance: number;
renderer?: HelpedHalftoneRenderer;
settings: HelpedHalftoneSettings;
};
@@ -1342,6 +1439,7 @@ export function HelpedHalftoneModel({
label,
modelUrl,
previewDistance,
renderer = 'legacy',
settings,
}: HelpedHalftoneModelProps) {
const [geometry, setGeometry] = useState<THREE.BufferGeometry | null>(null);
@@ -1371,6 +1469,17 @@ export function HelpedHalftoneModel({
return <StyledVisualMount aria-hidden />;
}
if (renderer === 'studio') {
return (
<StudioHelpedHalftoneCanvas
geometry={geometry}
initialPose={initialPose}
previewDistance={previewDistance}
settings={settings}
/>
);
}
return (
<HelpedHalftoneCanvas
geometry={geometry}
@@ -11,26 +11,33 @@ const MONEY_PREVIEW_DISTANCE = 5;
const MONEY_SETTINGS: HelpedHalftoneSettings = {
lighting: {
intensity: 1,
intensity: 3,
fillIntensity: 0,
ambientIntensity: 0,
angleDegrees: 103,
height: -3.6,
angleDegrees: 47,
height: 1.4,
},
material: {
roughness: 0.32,
metalness: 0.98,
surface: 'glass',
color: '#7d7d7d',
roughness: 0.26,
metalness: 0.15,
thickness: 15.58,
refraction: 3,
environmentPower: 5,
},
halftone: {
enabled: true,
scale: 14.97,
power: -0.25,
width: 1.4,
dashColor: '#FDFE98',
scale: 25.64,
power: -1.24,
width: 0.5,
imageContrast: 1,
dashColor: '#FEFFB7',
hoverDashColor: '#4A38F5',
},
animation: {
autoRotateEnabled: false,
breatheEnabled: false,
breatheEnabled: true,
cameraParallaxEnabled: false,
followHoverEnabled: false,
followDragEnabled: true,
@@ -39,31 +46,35 @@ const MONEY_SETTINGS: HelpedHalftoneSettings = {
dragFlowEnabled: false,
lightSweepEnabled: false,
rotateEnabled: false,
autoSpeed: 0.1,
autoWobble: 0,
breatheAmount: 0.04,
breatheSpeed: 0.8,
autoSpeed: 0.15,
autoWobble: 0.3,
breatheAmount: 0.015,
breatheSpeed: 0.25,
cameraParallaxAmount: 0.3,
cameraParallaxEase: 0.08,
driftAmount: 8,
hoverRange: 24,
hoverEase: 0.16,
hoverRange: 25,
hoverEase: 0.08,
hoverReturn: true,
dragSens: 0.008,
dragFriction: 0.08,
dragMomentum: true,
rotateAxis: '-xy',
rotateAxis: 'y',
rotatePreset: 'axis',
rotateSpeed: 0.1,
rotatePingPong: false,
floatAmplitude: 0.16,
floatSpeed: 0.8,
lightSweepHeightRange: 0.5,
lightSweepHeightRange: 0,
lightSweepRange: 28,
lightSweepSpeed: 0.2,
springDamping: 0.4,
lightSweepSpeed: 0.25,
springDamping: 0.5,
springReturnEnabled: true,
springStrength: 0.06,
springStrength: 0.1,
hoverHalftoneEnabled: false,
hoverHalftonePowerShift: 0.42,
hoverHalftoneRadius: 0.2,
hoverHalftoneWidthShift: -0.18,
hoverLightIntensity: 0.8,
hoverLightRadius: 0.2,
dragFlowDecay: 0.08,
@@ -79,14 +90,14 @@ const MONEY_SETTINGS: HelpedHalftoneSettings = {
};
const MONEY_INITIAL_POSE: HelpedHalftonePose = {
autoElapsed: 0.05,
autoElapsed: 9.604400000000004,
rotateElapsed: 0,
rotationX: 2.0543643190124016e-89,
rotationY: 1.3695919155712744e-87,
rotationZ: 0,
rotationX: 9.425994949837918e-229,
rotationY: 2.798289108321203e-229,
rotationZ: -2.301463116952276e-231,
targetRotationX: 0,
targetRotationY: 0,
timeElapsed: 482.5497999999506,
timeElapsed: 303.7692999998331,
};
export function Money() {
@@ -96,6 +107,7 @@ export function Money() {
label="money.glb"
modelUrl={GLB_URL}
previewDistance={MONEY_PREVIEW_DISTANCE}
renderer="studio"
settings={MONEY_SETTINGS}
/>
);
@@ -11,26 +11,33 @@ const SPACESHIP_PREVIEW_DISTANCE = 3.5;
const SPACESHIP_SETTINGS: HelpedHalftoneSettings = {
lighting: {
intensity: 1,
intensity: 3,
fillIntensity: 0,
ambientIntensity: 0,
angleDegrees: 103,
height: -3.6,
angleDegrees: 47,
height: 1.4,
},
material: {
roughness: 0.32,
metalness: 0.98,
surface: 'glass',
color: '#7d7d7d',
roughness: 0.26,
metalness: 0.15,
thickness: 15.58,
refraction: 3,
environmentPower: 5,
},
halftone: {
enabled: true,
scale: 14.97,
power: -0.25,
width: 1.4,
scale: 16.64,
power: -1.24,
width: 0.5,
imageContrast: 1,
dashColor: '#89FC9A',
hoverDashColor: '#89FC9A',
},
animation: {
autoRotateEnabled: false,
breatheEnabled: false,
breatheEnabled: true,
cameraParallaxEnabled: false,
followHoverEnabled: false,
followDragEnabled: true,
@@ -39,31 +46,35 @@ const SPACESHIP_SETTINGS: HelpedHalftoneSettings = {
dragFlowEnabled: false,
lightSweepEnabled: false,
rotateEnabled: false,
autoSpeed: 0.1,
autoWobble: 0,
breatheAmount: 0.04,
breatheSpeed: 0.8,
autoSpeed: 0.15,
autoWobble: 0.3,
breatheAmount: 0.015,
breatheSpeed: 0.25,
cameraParallaxAmount: 0.3,
cameraParallaxEase: 0.08,
driftAmount: 8,
hoverRange: 24,
hoverEase: 0.16,
hoverRange: 25,
hoverEase: 0.08,
hoverReturn: true,
dragSens: 0.008,
dragFriction: 0.08,
dragMomentum: true,
rotateAxis: '-xy',
rotateAxis: 'y',
rotatePreset: 'axis',
rotateSpeed: 0.1,
rotatePingPong: false,
floatAmplitude: 0.16,
floatSpeed: 0.8,
lightSweepHeightRange: 0.5,
lightSweepHeightRange: 0,
lightSweepRange: 28,
lightSweepSpeed: 0.2,
springDamping: 0.4,
lightSweepSpeed: 0.25,
springDamping: 0.5,
springReturnEnabled: true,
springStrength: 0.06,
springStrength: 0.1,
hoverHalftoneEnabled: false,
hoverHalftonePowerShift: 0.42,
hoverHalftoneRadius: 0.2,
hoverHalftoneWidthShift: -0.18,
hoverLightIntensity: 0.8,
hoverLightRadius: 0.2,
dragFlowDecay: 0.08,
@@ -96,6 +107,7 @@ export function Spaceship() {
label="spaceship.glb"
modelUrl={GLB_URL}
previewDistance={SPACESHIP_PREVIEW_DISTANCE}
renderer="studio"
settings={SPACESHIP_SETTINGS}
/>
);
@@ -11,26 +11,33 @@ const TARGET_PREVIEW_DISTANCE = 4;
const TARGET_SETTINGS: HelpedHalftoneSettings = {
lighting: {
intensity: 1,
intensity: 3,
fillIntensity: 0,
ambientIntensity: 0,
angleDegrees: 103,
height: -3.6,
angleDegrees: 47,
height: 1.4,
},
material: {
roughness: 0.32,
metalness: 0.98,
surface: 'glass',
color: '#7d7d7d',
roughness: 0.26,
metalness: 0.15,
thickness: 15.58,
refraction: 3,
environmentPower: 5,
},
halftone: {
enabled: true,
scale: 14.97,
power: -0.25,
width: 1.4,
scale: 25.64,
power: -1.24,
width: 0.5,
imageContrast: 1,
dashColor: '#ED87FC',
hoverDashColor: '#ED87FC',
},
animation: {
autoRotateEnabled: false,
breatheEnabled: false,
breatheEnabled: true,
cameraParallaxEnabled: false,
followHoverEnabled: false,
followDragEnabled: true,
@@ -39,31 +46,35 @@ const TARGET_SETTINGS: HelpedHalftoneSettings = {
dragFlowEnabled: false,
lightSweepEnabled: false,
rotateEnabled: false,
autoSpeed: 0.1,
autoWobble: 0,
breatheAmount: 0.04,
breatheSpeed: 0.8,
autoSpeed: 0.15,
autoWobble: 0.3,
breatheAmount: 0.015,
breatheSpeed: 0.25,
cameraParallaxAmount: 0.3,
cameraParallaxEase: 0.08,
driftAmount: 8,
hoverRange: 24,
hoverEase: 0.16,
hoverRange: 25,
hoverEase: 0.08,
hoverReturn: true,
dragSens: 0.008,
dragFriction: 0.08,
dragMomentum: true,
rotateAxis: '-xy',
rotateAxis: 'y',
rotatePreset: 'axis',
rotateSpeed: 0.1,
rotatePingPong: false,
floatAmplitude: 0.16,
floatSpeed: 0.8,
lightSweepHeightRange: 0.5,
lightSweepHeightRange: 0,
lightSweepRange: 28,
lightSweepSpeed: 0.2,
springDamping: 0.4,
lightSweepSpeed: 0.25,
springDamping: 0.5,
springReturnEnabled: true,
springStrength: 0.06,
springStrength: 0.1,
hoverHalftoneEnabled: false,
hoverHalftonePowerShift: 0.42,
hoverHalftoneRadius: 0.2,
hoverHalftoneWidthShift: -0.18,
hoverLightIntensity: 0.8,
hoverLightRadius: 0.2,
dragFlowDecay: 0.08,
@@ -96,6 +107,7 @@ export function Target() {
label="target.glb"
modelUrl={GLB_URL}
previewDistance={TARGET_PREVIEW_DISTANCE}
renderer="studio"
settings={TARGET_SETTINGS}
/>
);
@@ -1,10 +1,14 @@
export const SHARED_COMPANY_LOGO_URLS = {
airbnb: '/images/shared/companies/logos/airbnb.png',
anthropic: '/images/shared/companies/logos/anthropic.png',
cursor: '/images/shared/companies/logos/cursor.png',
figma: '/images/shared/companies/logos/figma.png',
github: '/images/shared/companies/logos/github.png',
linear: '/images/shared/companies/logos/linear.svg',
notion: '/images/shared/companies/logos/notion.png',
sequoia: '/images/shared/companies/logos/sequoia.png',
slack: '/images/shared/companies/logos/slack.png',
twenty: '/images/shared/companies/logos/twenty.svg',
} as const;
export const SHARED_PEOPLE_AVATAR_URLS = {
@@ -15,25 +15,24 @@ const HOVER_FADE_OUT = 7;
const HALFTONE_SETTINGS = {
animation: {
hoverHalftoneEnabled: true,
hoverHalftonePowerShift: 0.62,
hoverHalftoneRadius: 0.6,
hoverHalftoneEnabled: false,
hoverHalftonePowerShift: 0.42,
hoverHalftoneRadius: 0.45,
hoverHalftoneWidthShift: -0.18,
hoverLightEnabled: false,
hoverLightIntensity: 0.12,
hoverLightRadius: 0.8,
hoverLightEnabled: true,
hoverLightIntensity: 0.35,
hoverLightRadius: 0.42,
waveAmount: 2,
waveEnabled: false,
waveSpeed: 1,
},
halftone: {
dashColor: '#dddddd',
hoverDashColor: '#FFF',
imageContrast: 1.12,
minimumTone: 0.26,
power: 0.18,
scale: 12,
width: 0.72,
dashColor: '#868686',
hoverDashColor: '#F5F5F5',
imageContrast: 1,
power: 0.5,
scale: 8,
width: 0.3,
},
};
@@ -63,7 +62,6 @@ const imagePassthroughFragmentShader = /* glsl */ `
vec2 uv = vUv;
// Cover: match the underlying NextImage background crop.
if (imageAspect > viewAspect) {
float scale = viewAspect / imageAspect;
uv.x = (uv.x - 0.5) * scale + 0.5;
@@ -95,7 +93,6 @@ const halftoneFragmentShader = /* glsl */ `
uniform float s_4;
uniform vec3 dashColor;
uniform vec3 hoverDashColor;
uniform float minimumTone;
uniform float time;
uniform float waveAmount;
uniform float waveSpeed;
@@ -209,17 +206,15 @@ const halftoneFragmentShader = /* glsl */ `
);
float lightLift =
hoverLightStrength * hoverLightMask * mix(0.78, 1.18, motionBias) * 0.22;
float tonalAverage = (
float bandRadius = clamp(
(
sceneSample.r +
sceneSample.g +
sceneSample.b +
localPower * length(vec2(0.5))
) *
(1.0 / 3.0)
) + lightLift;
float bandRadius = clamp(
max(tonalAverage, minimumTone),
(1.0 / 3.0) +
lightLift,
0.0,
1.0
) * 1.86 * 0.5;
@@ -391,7 +386,6 @@ async function mountHalftoneCanvas({
logicalResolution: {
value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
},
minimumTone: { value: HALFTONE_SETTINGS.halftone.minimumTone },
s_3: { value: HALFTONE_SETTINGS.halftone.power },
s_4: { value: HALFTONE_SETTINGS.halftone.width },
tScene: { value: sceneTarget.texture },
@@ -6,7 +6,8 @@ import { StepperVisualFrame } from '../StepperVisualFrame/StepperVisualFrame';
import { StepperBackgroundHalftone } from './StepperBackgroundHalftone';
import { StepperLottie } from './StepperLottie';
const HOME_STEPPER_BACKGROUND = '/images/home/stepper/gears.jpg';
const HOME_STEPPER_BACKGROUND =
'/images/home/stepper/download-worker.webp';
const HOME_STEPPER_SHAPE = '/images/home/stepper/background-shape.webp';
type VisualProps = {
@@ -16,7 +17,7 @@ type VisualProps = {
export function Visual({ scrollProgress }: VisualProps) {
return (
<StepperVisualFrame
backgroundColor="#F5F5F5"
backgroundColor="#424242"
backgroundSrc={HOME_STEPPER_BACKGROUND}
backgroundOverlay={
<VisibleWhenTabActive>
@@ -86,7 +86,7 @@ export function Root(props: RootProps) {
<SignoffShape fillColor={shapeFillColor} />
) : null}
{page === Pages.Partner ? (
<GuideCrosshair crossX="calc(50% - 334px)" crossY="198px" />
<GuideCrosshair crossX="calc(50% + 334px)" crossY="198px" />
) : null}
<StyledContainer>{children}</StyledContainer>
</StyledSection>
@@ -0,0 +1,921 @@
'use client';
import { styled } from '@linaria/react';
import { type RefObject, useEffect, useRef } from 'react';
import * as THREE from 'three';
const DEFAULT_IMAGE_URL =
'/images/home/three-cards-feature/familiar-interface-gradient.webp';
const VIRTUAL_RENDER_HEIGHT = 800;
const REFERENCE_PREVIEW_DISTANCE = 4;
const PREVIEW_DISTANCE = REFERENCE_PREVIEW_DISTANCE;
const MIN_FOOTPRINT_SCALE = 0.001;
const HALFTONE_SCALE = 18;
const HALFTONE_POWER = -0.3;
const HALFTONE_WIDTH = 0.3;
const IMAGE_CONTRAST = 0.7;
const DASH_COLOR = '#777';
const HOVER_DASH_COLOR = '#777';
const HOVER_LIGHT_INTENSITY = 0.85;
const HOVER_LIGHT_RADIUS = 0.6;
const HOVER_HALFTONE_RADIUS = 0.45;
const IMAGE_POINTER_FOLLOW = 0.38;
const IMAGE_POINTER_VELOCITY_DAMPING = 0.82;
const IMAGE_HOVER_FADE_IN = 18;
const IMAGE_HOVER_FADE_OUT = 7;
export type HalftoneBackdropConfig = {
activeHoverX?: number;
activeHoverY?: number;
dashColor?: string;
halftonePower?: number;
halftoneScale?: number;
halftoneWidth?: number;
hoverDashColor?: string;
hoverHalftoneRadius?: number;
hoverLightIntensity?: number;
hoverLightRadius?: number;
imageContrast?: number;
previewDistance?: number;
};
type ResolvedHalftoneBackdropConfig = Required<HalftoneBackdropConfig>;
const DEFAULT_HALFTONE_BACKDROP_CONFIG: ResolvedHalftoneBackdropConfig = {
activeHoverX: 0.16,
activeHoverY: 0.46,
dashColor: DASH_COLOR,
halftonePower: HALFTONE_POWER,
halftoneScale: HALFTONE_SCALE,
halftoneWidth: HALFTONE_WIDTH,
hoverDashColor: HOVER_DASH_COLOR,
hoverHalftoneRadius: HOVER_HALFTONE_RADIUS,
hoverLightIntensity: HOVER_LIGHT_INTENSITY,
hoverLightRadius: HOVER_LIGHT_RADIUS,
imageContrast: IMAGE_CONTRAST,
previewDistance: PREVIEW_DISTANCE,
};
const passThroughVertexShader = `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = vec4(position, 1.0);
}
`;
const blurFragmentShader = `
precision highp float;
uniform sampler2D tInput;
uniform vec2 dir;
uniform vec2 res;
varying vec2 vUv;
void main() {
vec4 sum = vec4(0.0);
vec2 px = dir / res;
float w[5];
w[0] = 0.227027;
w[1] = 0.1945946;
w[2] = 0.1216216;
w[3] = 0.054054;
w[4] = 0.016216;
sum += texture2D(tInput, vUv) * w[0];
for (int i = 1; i < 5; i++) {
float fi = float(i) * 3.0;
sum += texture2D(tInput, vUv + px * fi) * w[i];
sum += texture2D(tInput, vUv - px * fi) * w[i];
}
gl_FragColor = sum;
}
`;
const halftoneFragmentShader = `
precision highp float;
uniform sampler2D tScene;
uniform vec2 effectResolution;
uniform vec2 logicalResolution;
uniform float tile;
uniform float s_3;
uniform float s_4;
uniform vec3 dashColor;
uniform vec3 hoverDashColor;
uniform float footprintScale;
uniform vec2 interactionUv;
uniform vec2 interactionVelocity;
uniform float hoverLightStrength;
uniform float hoverLightRadius;
varying vec2 vUv;
float distSegment(in vec2 p, in vec2 a, in vec2 b) {
vec2 pa = p - a;
vec2 ba = b - a;
float denom = max(dot(ba, ba), 0.000001);
float h = clamp(dot(pa, ba) / denom, 0.0, 1.0);
return length(pa - ba * h);
}
float lineSimpleEt(in vec2 p, in float r, in float thickness) {
vec2 a = vec2(0.5) + vec2(-r, 0.0);
vec2 b = vec2(0.5) + vec2(r, 0.0);
float distToSegment = distSegment(p, a, b);
float halfThickness = thickness * r;
return distToSegment - halfThickness;
}
void main() {
vec2 fragCoord =
(gl_FragCoord.xy / max(effectResolution, vec2(1.0))) * logicalResolution;
float halftoneSize = max(tile * max(footprintScale, 0.001), 1.0);
vec2 pointerPx = interactionUv * logicalResolution;
vec2 fragDelta = fragCoord - pointerPx;
float fragDist = length(fragDelta);
vec2 radialDir = fragDist > 0.001 ? fragDelta / fragDist : vec2(0.0, 1.0);
float velocityMagnitude = length(interactionVelocity);
vec2 motionDir = velocityMagnitude > 0.001
? interactionVelocity / velocityMagnitude
: vec2(0.0, 0.0);
float motionBias = velocityMagnitude > 0.001
? dot(-radialDir, motionDir) * 0.5 + 0.5
: 0.5;
float hoverLightMask = 0.0;
if (hoverLightStrength > 0.0) {
float lightRadiusPx = hoverLightRadius * logicalResolution.y;
hoverLightMask = smoothstep(lightRadiusPx, 0.0, fragDist);
}
vec2 effectCoord = fragCoord;
vec2 cellIndex = floor(effectCoord / halftoneSize);
vec2 sampleUv = clamp(
(cellIndex + 0.5) * halftoneSize / logicalResolution,
vec2(0.0),
vec2(1.0)
);
vec2 cellUv = fract(effectCoord / halftoneSize);
vec4 sceneSample = texture2D(tScene, sampleUv);
float mask = smoothstep(0.02, 0.08, sceneSample.a);
float lightLift =
hoverLightStrength * hoverLightMask * mix(0.78, 1.18, motionBias) * 0.22;
float bandRadius = clamp(
(
(
sceneSample.r +
sceneSample.g +
sceneSample.b +
s_3 * length(vec2(0.5))
) *
(1.0 / 3.0)
) + lightLift,
0.0,
1.0
) * 1.86 * 0.5;
float alpha = 0.0;
if (bandRadius > 0.0001) {
float signedDistance = lineSimpleEt(cellUv, bandRadius, s_4);
float edge = 0.02;
alpha = (1.0 - smoothstep(0.0, edge, signedDistance)) * mask;
}
float hoverStrength = clamp(hoverLightMask * hoverLightStrength, 0.0, 1.0);
vec3 activeDashColor = mix(dashColor, hoverDashColor, hoverStrength);
vec3 color = activeDashColor * alpha;
gl_FragColor = vec4(color, alpha);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}
`;
const imagePassthroughFragmentShader = `
precision highp float;
uniform sampler2D tImage;
uniform vec2 imageSize;
uniform vec2 viewportSize;
uniform float zoom;
uniform float contrast;
varying vec2 vUv;
void main() {
float imageAspect = imageSize.x / imageSize.y;
float viewAspect = viewportSize.x / viewportSize.y;
vec2 uv = vUv;
if (imageAspect > viewAspect) {
float scale = viewAspect / imageAspect;
uv.x = (uv.x - 0.5) * scale + 0.5;
} else {
float scale = imageAspect / viewAspect;
uv.y = (uv.y - 0.5) * scale + 0.5;
}
uv = (uv - 0.5) / zoom + 0.5;
uv.y = 1.0 - uv.y;
float inBounds = step(0.0, uv.x) * step(uv.x, 1.0)
* step(0.0, uv.y) * step(uv.y, 1.0);
vec4 color = texture2D(tImage, clamp(uv, 0.0, 1.0));
vec3 contrastColor = clamp((color.rgb - 0.5) * contrast + 0.5, 0.0, 1.0);
gl_FragColor = vec4(contrastColor, inBounds);
}
`;
type Rect = {
x: number;
y: number;
width: number;
height: number;
};
type InteractionState = {
hoverStrength: number;
mouseX: number;
mouseY: number;
pointerInside: boolean;
pointerVelocityX: number;
pointerVelocityY: number;
smoothedMouseX: number;
smoothedMouseY: number;
};
function clampRectToViewport(
rect: Rect,
viewportWidth: number,
viewportHeight: number,
) {
const minX = Math.max(rect.x, 0);
const minY = Math.max(rect.y, 0);
const maxX = Math.min(rect.x + rect.width, viewportWidth);
const maxY = Math.min(rect.y + rect.height, viewportHeight);
if (maxX <= minX || maxY <= minY) {
return null;
}
return {
x: minX,
y: minY,
width: maxX - minX,
height: maxY - minY,
};
}
function getRectArea(rect: Rect | null) {
if (!rect) {
return 0;
}
return Math.max(rect.width, 0) * Math.max(rect.height, 0);
}
function getImagePreviewZoom(previewDistance: number) {
return REFERENCE_PREVIEW_DISTANCE / Math.max(previewDistance, 0.001);
}
function getContainedImageRect({
imageHeight,
imageWidth,
viewportHeight,
viewportWidth,
zoom,
}: {
imageHeight: number;
imageWidth: number;
viewportHeight: number;
viewportWidth: number;
zoom: number;
}) {
if (
imageWidth <= 0 ||
imageHeight <= 0 ||
viewportWidth <= 0 ||
viewportHeight <= 0
) {
return null;
}
const imageAspect = imageWidth / imageHeight;
const viewAspect = viewportWidth / viewportHeight;
let fittedWidth = viewportWidth;
let fittedHeight = viewportHeight;
if (imageAspect > viewAspect) {
fittedHeight = viewportWidth / imageAspect;
} else {
fittedWidth = viewportHeight * imageAspect;
}
const scaledWidth = fittedWidth * zoom;
const scaledHeight = fittedHeight * zoom;
return clampRectToViewport(
{
x: (viewportWidth - scaledWidth) * 0.5,
y: (viewportHeight - scaledHeight) * 0.5,
width: scaledWidth,
height: scaledHeight,
},
viewportWidth,
viewportHeight,
);
}
function getFootprintScaleFromRects(
currentRect: Rect | null,
referenceRect: Rect | null,
) {
const currentArea = getRectArea(currentRect);
const referenceArea = getRectArea(referenceRect);
if (currentArea <= 0 || referenceArea <= 0) {
return 1;
}
return Math.max(Math.sqrt(currentArea / referenceArea), MIN_FOOTPRINT_SCALE);
}
function getImageFootprintScale({
imageHeight,
imageWidth,
previewDistance,
viewportHeight,
viewportWidth,
}: {
imageHeight: number;
imageWidth: number;
previewDistance: number;
viewportHeight: number;
viewportWidth: number;
}) {
const currentRect = getContainedImageRect({
imageHeight,
imageWidth,
viewportHeight,
viewportWidth,
zoom: getImagePreviewZoom(previewDistance),
});
const referenceRect = getContainedImageRect({
imageHeight,
imageWidth,
viewportHeight,
viewportWidth,
zoom: 1,
});
return getFootprintScaleFromRects(currentRect, referenceRect);
}
function createRenderTarget(width: number, height: number) {
return new THREE.WebGLRenderTarget(width, height, {
minFilter: THREE.LinearFilter,
magFilter: THREE.LinearFilter,
format: THREE.RGBAFormat,
});
}
function createInteractionState(): InteractionState {
return {
hoverStrength: 0,
mouseX: 0.5,
mouseY: 0.5,
pointerInside: false,
pointerVelocityX: 0,
pointerVelocityY: 0,
smoothedMouseX: 0.5,
smoothedMouseY: 0.5,
};
}
function loadImage(imageUrl: string) {
return new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error(`Failed to load image: ${imageUrl}`));
image.src = imageUrl;
});
}
async function mountFamiliarInterfaceGradient({
container,
config,
imageUrl,
isExternallyActive,
pointerTarget,
}: {
container: HTMLDivElement;
config: ResolvedHalftoneBackdropConfig;
imageUrl: string;
isExternallyActive: () => boolean;
pointerTarget?: HTMLElement | null;
}) {
const HOVER_STRENGTH_STOP_EPSILON = 0.001;
const POINTER_POSITION_STOP_EPSILON = 0.001;
const POINTER_VELOCITY_STOP_EPSILON = 0.0001;
const image = await loadImage(imageUrl);
const getWidth = () => Math.max(container.clientWidth, 1);
const getHeight = () => Math.max(container.clientHeight, 1);
const getVirtualHeight = () => Math.max(VIRTUAL_RENDER_HEIGHT, getHeight());
const getVirtualWidth = () =>
Math.max(
Math.round(getVirtualHeight() * (getWidth() / Math.max(getHeight(), 1))),
1,
);
const renderer = new THREE.WebGLRenderer({ antialias: false, alpha: true });
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.setPixelRatio(1);
renderer.setClearColor(0x000000, 0);
renderer.setSize(getVirtualWidth(), getVirtualHeight(), false);
const canvas = renderer.domElement;
canvas.style.cursor = 'default';
canvas.style.display = 'block';
canvas.style.height = '100%';
canvas.style.touchAction = 'none';
canvas.style.width = '100%';
container.appendChild(canvas);
const trackingElement = pointerTarget ?? canvas;
const imageTexture = new THREE.Texture(image);
imageTexture.colorSpace = THREE.SRGBColorSpace;
imageTexture.needsUpdate = true;
const sceneTarget = createRenderTarget(getVirtualWidth(), getVirtualHeight());
const blurTargetA = createRenderTarget(getVirtualWidth(), getVirtualHeight());
const blurTargetB = createRenderTarget(getVirtualWidth(), getVirtualHeight());
const fullScreenGeometry = new THREE.PlaneGeometry(2, 2);
const orthographicCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
const imageMaterial = new THREE.ShaderMaterial({
uniforms: {
contrast: { value: config.imageContrast },
imageSize: { value: new THREE.Vector2(image.width, image.height) },
tImage: { value: imageTexture },
viewportSize: {
value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
},
zoom: { value: getImagePreviewZoom(config.previewDistance) },
},
fragmentShader: imagePassthroughFragmentShader,
vertexShader: passThroughVertexShader,
});
const blurHorizontalMaterial = new THREE.ShaderMaterial({
uniforms: {
dir: { value: new THREE.Vector2(1, 0) },
res: { value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()) },
tInput: { value: null },
},
fragmentShader: blurFragmentShader,
vertexShader: passThroughVertexShader,
});
const blurVerticalMaterial = new THREE.ShaderMaterial({
uniforms: {
dir: { value: new THREE.Vector2(0, 1) },
res: { value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()) },
tInput: { value: null },
},
fragmentShader: blurFragmentShader,
vertexShader: passThroughVertexShader,
});
const halftoneMaterial = new THREE.ShaderMaterial({
fragmentShader: halftoneFragmentShader,
transparent: true,
uniforms: {
dashColor: { value: new THREE.Color(config.dashColor) },
effectResolution: {
value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
},
footprintScale: { value: 1 },
hoverDashColor: { value: new THREE.Color(config.hoverDashColor) },
hoverLightRadius: { value: config.hoverLightRadius },
hoverLightStrength: { value: 0 },
interactionUv: { value: new THREE.Vector2(0.5, 0.5) },
interactionVelocity: { value: new THREE.Vector2(0, 0) },
logicalResolution: {
value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
},
s_3: { value: config.halftonePower },
s_4: { value: config.halftoneWidth },
tScene: { value: sceneTarget.texture },
tile: { value: config.halftoneScale },
},
vertexShader: passThroughVertexShader,
});
const fullScreenMesh = new THREE.Mesh(fullScreenGeometry, imageMaterial);
const blurHorizontalMesh = new THREE.Mesh(
fullScreenGeometry,
blurHorizontalMaterial,
);
const blurVerticalMesh = new THREE.Mesh(
fullScreenGeometry,
blurVerticalMaterial,
);
const postProcessMesh = new THREE.Mesh(fullScreenGeometry, halftoneMaterial);
const imageScene = new THREE.Scene();
imageScene.add(fullScreenMesh);
const blurHorizontalScene = new THREE.Scene();
blurHorizontalScene.add(blurHorizontalMesh);
const blurVerticalScene = new THREE.Scene();
blurVerticalScene.add(blurVerticalMesh);
const postScene = new THREE.Scene();
postScene.add(postProcessMesh);
const updateViewportUniforms = (
logicalWidth: number,
logicalHeight: number,
effectWidth: number,
effectHeight: number,
) => {
blurHorizontalMaterial.uniforms.res.value.set(effectWidth, effectHeight);
blurVerticalMaterial.uniforms.res.value.set(effectWidth, effectHeight);
halftoneMaterial.uniforms.effectResolution.value.set(
effectWidth,
effectHeight,
);
halftoneMaterial.uniforms.logicalResolution.value.set(
logicalWidth,
logicalHeight,
);
imageMaterial.uniforms.viewportSize.value.set(logicalWidth, logicalHeight);
};
const getHalftoneScale = () =>
getImageFootprintScale({
imageHeight: image.height,
imageWidth: image.width,
previewDistance: config.previewDistance,
viewportHeight: getVirtualHeight(),
viewportWidth: getVirtualWidth(),
});
const interaction = createInteractionState();
let animationFrameId: number | null = null;
let lastFrameTime: number | null = null;
const renderBackdrop = (deltaSeconds: number) => {
const externalActive = isExternallyActive();
const isHoverActive = externalActive || interaction.pointerInside;
const targetHoverStrength = isHoverActive ? 1 : 0;
const hoverEasing =
1 -
Math.exp(
-deltaSeconds *
(isHoverActive ? IMAGE_HOVER_FADE_IN : IMAGE_HOVER_FADE_OUT),
);
interaction.hoverStrength +=
(targetHoverStrength - interaction.hoverStrength) * hoverEasing;
if (
Math.abs(targetHoverStrength - interaction.hoverStrength) <=
HOVER_STRENGTH_STOP_EPSILON
) {
interaction.hoverStrength = targetHoverStrength;
}
const targetMouseX =
externalActive && !interaction.pointerInside
? config.activeHoverX
: interaction.mouseX;
const targetMouseY =
externalActive && !interaction.pointerInside
? config.activeHoverY
: interaction.mouseY;
interaction.smoothedMouseX +=
(targetMouseX - interaction.smoothedMouseX) * IMAGE_POINTER_FOLLOW;
interaction.smoothedMouseY +=
(targetMouseY - interaction.smoothedMouseY) * IMAGE_POINTER_FOLLOW;
if (
Math.abs(targetMouseX - interaction.smoothedMouseX) <=
POINTER_POSITION_STOP_EPSILON
) {
interaction.smoothedMouseX = targetMouseX;
}
if (
Math.abs(targetMouseY - interaction.smoothedMouseY) <=
POINTER_POSITION_STOP_EPSILON
) {
interaction.smoothedMouseY = targetMouseY;
}
interaction.pointerVelocityX *= IMAGE_POINTER_VELOCITY_DAMPING;
interaction.pointerVelocityY *= IMAGE_POINTER_VELOCITY_DAMPING;
if (
Math.abs(interaction.pointerVelocityX) <= POINTER_VELOCITY_STOP_EPSILON
) {
interaction.pointerVelocityX = 0;
}
if (
Math.abs(interaction.pointerVelocityY) <= POINTER_VELOCITY_STOP_EPSILON
) {
interaction.pointerVelocityY = 0;
}
halftoneMaterial.uniforms.interactionUv.value.set(
interaction.smoothedMouseX,
1 - interaction.smoothedMouseY,
);
halftoneMaterial.uniforms.interactionVelocity.value.set(
interaction.pointerVelocityX * getVirtualWidth(),
-interaction.pointerVelocityY * getVirtualHeight(),
);
halftoneMaterial.uniforms.hoverLightStrength.value =
config.hoverLightIntensity * interaction.hoverStrength;
halftoneMaterial.uniforms.hoverLightRadius.value =
config.hoverHalftoneRadius + config.hoverLightRadius * 0.5;
halftoneMaterial.uniforms.footprintScale.value = getHalftoneScale();
imageMaterial.uniforms.zoom.value = getImagePreviewZoom(
config.previewDistance,
);
renderer.setRenderTarget(sceneTarget);
renderer.render(imageScene, orthographicCamera);
blurHorizontalMaterial.uniforms.tInput.value = sceneTarget.texture;
renderer.setRenderTarget(blurTargetA);
renderer.render(blurHorizontalScene, orthographicCamera);
blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture;
renderer.setRenderTarget(blurTargetB);
renderer.render(blurVerticalScene, orthographicCamera);
blurHorizontalMaterial.uniforms.tInput.value = blurTargetB.texture;
renderer.setRenderTarget(blurTargetA);
renderer.render(blurHorizontalScene, orthographicCamera);
blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture;
renderer.setRenderTarget(blurTargetB);
renderer.render(blurVerticalScene, orthographicCamera);
renderer.setRenderTarget(null);
renderer.clear();
renderer.render(postScene, orthographicCamera);
return (
isHoverActive ||
interaction.hoverStrength !== targetHoverStrength ||
interaction.smoothedMouseX !== targetMouseX ||
interaction.smoothedMouseY !== targetMouseY ||
interaction.pointerVelocityX !== 0 ||
interaction.pointerVelocityY !== 0
);
};
const runFrame = (timestamp: number) => {
animationFrameId = null;
const deltaSeconds =
lastFrameTime === null
? 1 / 60
: Math.min((timestamp - lastFrameTime) / 1000, 0.1);
lastFrameTime = timestamp;
if (renderBackdrop(deltaSeconds)) {
animationFrameId = window.requestAnimationFrame(runFrame);
return;
}
lastFrameTime = null;
};
const ensureAnimationLoop = () => {
if (animationFrameId !== null) {
return;
}
animationFrameId = window.requestAnimationFrame(runFrame);
};
const syncSize = () => {
const virtualWidth = getVirtualWidth();
const virtualHeight = getVirtualHeight();
renderer.setSize(virtualWidth, virtualHeight, false);
sceneTarget.setSize(virtualWidth, virtualHeight);
blurTargetA.setSize(virtualWidth, virtualHeight);
blurTargetB.setSize(virtualWidth, virtualHeight);
updateViewportUniforms(
virtualWidth,
virtualHeight,
virtualWidth,
virtualHeight,
);
if (animationFrameId === null) {
renderBackdrop(0);
}
};
const resizeObserver = new ResizeObserver(syncSize);
resizeObserver.observe(container);
syncSize();
const updatePointerPosition = (
event: PointerEvent,
options?: { resetVelocity?: boolean },
) => {
const rect = trackingElement.getBoundingClientRect();
const width = Math.max(rect.width, 1);
const height = Math.max(rect.height, 1);
const nextMouseX = THREE.MathUtils.clamp(
(event.clientX - rect.left) / width,
0,
1,
);
const nextMouseY = THREE.MathUtils.clamp(
(event.clientY - rect.top) / height,
0,
1,
);
const deltaX = nextMouseX - interaction.mouseX;
const deltaY = nextMouseY - interaction.mouseY;
interaction.mouseX = nextMouseX;
interaction.mouseY = nextMouseY;
interaction.pointerInside =
event.clientX >= rect.left &&
event.clientX <= rect.right &&
event.clientY >= rect.top &&
event.clientY <= rect.bottom;
if (options?.resetVelocity) {
interaction.pointerVelocityX = 0;
interaction.pointerVelocityY = 0;
interaction.smoothedMouseX = nextMouseX;
interaction.smoothedMouseY = nextMouseY;
return;
}
interaction.pointerVelocityX = deltaX;
interaction.pointerVelocityY = deltaY;
};
const handlePointerMove = (event: PointerEvent) => {
const shouldReset = !interaction.pointerInside;
updatePointerPosition(
event,
shouldReset ? { resetVelocity: true } : undefined,
);
ensureAnimationLoop();
};
const handlePointerLeave = () => {
interaction.pointerInside = false;
interaction.pointerVelocityX = 0;
interaction.pointerVelocityY = 0;
ensureAnimationLoop();
};
trackingElement.addEventListener('pointermove', handlePointerMove);
trackingElement.addEventListener('pointerleave', handlePointerLeave);
if (isExternallyActive()) {
ensureAnimationLoop();
}
return {
dispose: () => {
if (animationFrameId !== null) {
window.cancelAnimationFrame(animationFrameId);
}
resizeObserver.disconnect();
trackingElement.removeEventListener('pointermove', handlePointerMove);
trackingElement.removeEventListener('pointerleave', handlePointerLeave);
blurHorizontalMaterial.dispose();
blurVerticalMaterial.dispose();
halftoneMaterial.dispose();
imageMaterial.dispose();
imageTexture.dispose();
fullScreenGeometry.dispose();
sceneTarget.dispose();
blurTargetA.dispose();
blurTargetB.dispose();
renderer.dispose();
if (canvas.parentNode === container) {
container.removeChild(canvas);
}
},
wake: ensureAnimationLoop,
};
}
const VisualMount = styled.div`
background: transparent;
display: block;
height: 100%;
min-width: 0;
width: 100%;
`;
type FamiliarInterfaceGradientBackdropProps = {
active?: boolean;
config?: HalftoneBackdropConfig;
imageUrl?: string;
pointerTargetRef?: RefObject<HTMLElement | null>;
};
export function FamiliarInterfaceGradientBackdrop({
active = false,
config,
imageUrl = DEFAULT_IMAGE_URL,
pointerTargetRef,
}: FamiliarInterfaceGradientBackdropProps) {
const mountRef = useRef<HTMLDivElement>(null);
const activeRef = useRef(active);
const wakeRef = useRef<VoidFunction | null>(null);
const resolvedConfig = {
...DEFAULT_HALFTONE_BACKDROP_CONFIG,
...config,
};
activeRef.current = active;
useEffect(() => {
const container = mountRef.current;
if (!container) {
return;
}
let dispose: VoidFunction | undefined;
let cancelled = false;
void mountFamiliarInterfaceGradient({
config: resolvedConfig,
container,
imageUrl,
isExternallyActive: () => activeRef.current,
pointerTarget: pointerTargetRef?.current,
})
.then((mountedGradient) => {
if (cancelled) {
mountedGradient.dispose();
return;
}
wakeRef.current = mountedGradient.wake;
dispose = mountedGradient.dispose;
})
.catch((error) => {
console.error(error);
});
return () => {
cancelled = true;
wakeRef.current = null;
dispose?.();
};
}, [
imageUrl,
resolvedConfig.dashColor,
resolvedConfig.halftonePower,
resolvedConfig.halftoneScale,
resolvedConfig.halftoneWidth,
resolvedConfig.hoverDashColor,
resolvedConfig.hoverHalftoneRadius,
resolvedConfig.hoverLightIntensity,
resolvedConfig.hoverLightRadius,
resolvedConfig.imageContrast,
resolvedConfig.previewDistance,
]);
useEffect(() => {
wakeRef.current?.();
}, [active]);
return <VisualMount aria-hidden ref={mountRef} />;
}
@@ -9,10 +9,13 @@ import { theme } from '@/theme';
import { styled } from '@linaria/react';
import {
type PointerEvent as ReactPointerEvent,
type RefObject,
useEffect,
useLayoutEffect,
useRef,
useState,
} from 'react';
import { FamiliarInterfaceGradientBackdrop } from './FamiliarInterfaceGradientBackdrop';
import {
IconBuildingSkyscraper,
IconCalendarEvent,
@@ -40,6 +43,8 @@ const FIGMA_FIELD_STACK_GAP = 1.839;
const FIGMA_CARD_RADIUS = 3.677;
const FIGMA_CARD_SHADOW =
'0px 0px 3.677px rgba(0, 0, 0, 0.08), 0px 1.839px 3.677px rgba(0, 0, 0, 0.04)';
const FIGMA_COLUMN_GAP = 8;
const FIGMA_COLUMN_SIDE_PADDING = 5.516;
const FIGMA_HEADER_PADDING_X = 5.516;
const FIGMA_HEADER_PADDING_TOP = 7.354;
const FIGMA_HEADER_PADDING_BOTTOM = 3.677;
@@ -66,15 +71,20 @@ type CardPlacement =
| { laneIndex: LaneIndex; type: 'lane' };
type DropTarget = { cardIndex: number; laneIndex: LaneIndex };
type LaneCards = [CardId[], CardId[]];
type FamiliarInterfaceVisualProps = { active?: boolean };
type FamiliarInterfaceVisualProps = {
active?: boolean;
backgroundImageRotationDeg?: number;
backgroundImageSrc?: string;
pointerTargetRef?: RefObject<HTMLElement | null>;
};
const COLORS = {
backdrop: '#1b1b1b',
backdropStripe: 'rgba(255, 255, 255, 0.28)',
border: '#ebebeb',
borderLight: '#f1f1f1',
borderStrong: '#d6d6d6',
boardSurface: '#ffffff',
cardSurface: '#fcfcfc',
activeCardBorder: '#b5ccff',
activeCardSurface: '#e8f1ff',
imageAreaSurface: '#f5f5f3',
@@ -288,7 +298,7 @@ const SceneViewport = styled.div`
height: ${SCENE_HEIGHT}px;
left: 50%;
position: absolute;
top: 9px;
top: 0;
transform: translateX(-50%) scale(${SCENE_SCALE});
transform-origin: top center;
width: ${SCENE_WIDTH}px;
@@ -309,35 +319,20 @@ const SceneFrame = styled.div`
overflow: hidden;
position: relative;
width: 411px;
`;
&::before {
background:
radial-gradient(
circle at 1px 1px,
rgba(255, 255, 255, 0.09) 1px,
transparent 0
)
right top / 12px 12px repeat,
linear-gradient(rgba(255, 255, 255, 0.02), rgba(255, 255, 255, 0.02));
content: '';
inset: 0;
opacity: 0.42;
pointer-events: none;
position: absolute;
}
&::after {
background: repeating-linear-gradient(
180deg,
transparent 0 11px,
${COLORS.backdropStripe} 11px 15px,
transparent 15px 27px
);
content: '';
inset: 0;
pointer-events: none;
position: absolute;
}
const SceneBackdrop = styled.div<{
$backgroundImageRotationDeg?: number;
}>`
background-color: ${COLORS.backdrop};
inset: 0;
overflow: hidden;
pointer-events: none;
position: absolute;
transform: rotate(
${({ $backgroundImageRotationDeg = 0 }) => $backgroundImageRotationDeg}deg
);
transform-origin: center center;
`;
const BoardGroup = styled.div<{ $active: boolean }>`
@@ -434,10 +429,13 @@ const BoardTitleCount = styled.span`
`;
const ColumnsHeaderGrid = styled.div`
background: ${COLORS.boardSurface};
display: grid;
grid-template-columns: repeat(2, 186.355px);
justify-content: center;
min-height: 29.354px;
padding-left: 7.354px;
position: relative;
z-index: 1;
`;
const LaneHeader = styled.div`
@@ -446,7 +444,7 @@ const LaneHeader = styled.div`
display: flex;
gap: 4px;
min-height: 29.354px;
padding: 7.354px 7.354px 0;
padding: 7.354px ${FIGMA_COLUMN_SIDE_PADDING}px 0;
&:last-child {
border-right: none;
@@ -480,18 +478,18 @@ const LaneCount = styled.span`
const ColumnsGrid = styled.div`
display: grid;
grid-template-columns: repeat(2, 186.355px);
justify-content: center;
flex: 1 1 auto;
min-height: 0;
padding-left: 7.354px;
`;
const LaneBody = styled.div`
border-right: 1px solid ${COLORS.borderLight};
display: flex;
flex-direction: column;
gap: 7.354px;
gap: ${FIGMA_COLUMN_GAP}px;
min-height: 0;
padding: 7.354px 7.354px 8px;
padding: 7.354px ${FIGMA_COLUMN_SIDE_PADDING}px 8px;
&:last-child {
border-right: none;
@@ -511,7 +509,7 @@ const AddCardRow = styled.div`
const OpportunityCard = styled.div<{ $variant?: 'active' | 'board' }>`
background: ${({ $variant }) =>
$variant === 'active' ? COLORS.activeCardSurface : COLORS.boardSurface};
$variant === 'active' ? COLORS.activeCardSurface : COLORS.cardSurface};
border: ${({ $variant }) => ($variant === 'active' ? '0.919px' : '1px')} solid
${({ $variant }) =>
$variant === 'active' ? COLORS.activeCardBorder : COLORS.border};
@@ -519,7 +517,26 @@ const OpportunityCard = styled.div<{ $variant?: 'active' | 'board' }>`
box-shadow: ${FIGMA_CARD_SHADOW};
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
transition: background-color 120ms ease;
width: ${FIGMA_CARD_WIDTH}px;
&::after {
background: rgba(0, 0, 0, 0.04);
content: '';
inset: 0;
opacity: 0;
pointer-events: none;
position: absolute;
transition: opacity 120ms ease;
}
@media (hover: hover) {
&:hover::after {
opacity: ${({ $variant }) => ($variant === 'board' ? 1 : 0)};
}
}
`;
const CardHeader = styled.div`
@@ -1163,7 +1180,11 @@ function OpportunityPreviewCard({
export function FamiliarInterfaceVisual({
active = false,
backgroundImageRotationDeg,
backgroundImageSrc,
pointerTargetRef,
}: FamiliarInterfaceVisualProps) {
const rootRef = useRef<HTMLDivElement>(null);
const sceneFrameRef = useRef<HTMLDivElement>(null);
const interactionLayerRef = useRef<HTMLDivElement>(null);
const laneBodyRefs = useRef<(HTMLDivElement | null)[]>([]);
@@ -1187,8 +1208,11 @@ export function FamiliarInterfaceVisual({
const [draggedCardId, setDraggedCardId] = useState<CardId | null>(null);
const [hasDraggedCard, setHasDraggedCard] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const activePointerIdRef = useRef<number | null>(null);
const dragStateRef = useRef<{
cardId: CardId;
lastClientX: number;
lastClientY: number;
maxX: number;
maxY: number;
minX: number;
@@ -1272,6 +1296,51 @@ export function FamiliarInterfaceVisual({
pendingCardAnimationRectsRef.current = nextCardRects;
};
const releaseActivePointerCapture = () => {
const activePointerId = activePointerIdRef.current;
const interactionLayer = interactionLayerRef.current;
if (
activePointerId !== null &&
interactionLayer?.hasPointerCapture(activePointerId)
) {
interactionLayer.releasePointerCapture(activePointerId);
}
activePointerIdRef.current = null;
};
const clearDragState = () => {
dragStateRef.current = null;
setActiveCardId(null);
setDraggedCardId(null);
setIsDragging(false);
};
const updateDragOffsetFromPointer = (clientX: number, clientY: number) => {
const currentDragState = dragStateRef.current;
if (currentDragState === null) {
return;
}
currentDragState.lastClientX = clientX;
currentDragState.lastClientY = clientY;
const nextX = clamp(
currentDragState.originX + clientX - currentDragState.pointerX,
currentDragState.minX,
currentDragState.maxX,
);
const nextY = clamp(
currentDragState.originY + clientY - currentDragState.pointerY,
currentDragState.minY,
currentDragState.maxY,
);
setDragOffset({ x: nextX, y: nextY });
};
const getDropTarget = (
clientX: number,
clientY: number,
@@ -1332,6 +1401,10 @@ export function FamiliarInterfaceVisual({
return;
}
if (dragStateRef.current !== null) {
return;
}
setHasDraggedCard(true);
setActiveCardId(cardId);
setDraggedCardId(cardId);
@@ -1347,6 +1420,8 @@ export function FamiliarInterfaceVisual({
dragStateRef.current = {
cardId,
lastClientX: event.clientX,
lastClientY: event.clientY,
maxX: interactionLayerRect.width - cardRect.width,
maxY: interactionLayerRect.height - cardRect.height,
minX: 0,
@@ -1359,37 +1434,11 @@ export function FamiliarInterfaceVisual({
};
setDragOffset({ x: originX, y: originY });
setIsDragging(true);
event.currentTarget.setPointerCapture(event.pointerId);
activePointerIdRef.current = event.pointerId;
interactionLayerRef.current?.setPointerCapture(event.pointerId);
};
const handlePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
if (dragStateRef.current === null) {
return;
}
event.preventDefault();
const nextX = clamp(
dragStateRef.current.originX +
event.clientX -
dragStateRef.current.pointerX,
dragStateRef.current.minX,
dragStateRef.current.maxX,
);
const nextY = clamp(
dragStateRef.current.originY +
event.clientY -
dragStateRef.current.pointerY,
dragStateRef.current.minY,
dragStateRef.current.maxY,
);
setDragOffset({ x: nextX, y: nextY });
};
const handlePointerUp = (event: ReactPointerEvent<HTMLDivElement>) => {
event.preventDefault();
const finishDragAtPosition = (clientX: number, clientY: number) => {
const currentDragState = dragStateRef.current;
if (currentDragState === null) {
@@ -1398,22 +1447,18 @@ export function FamiliarInterfaceVisual({
const releasedOffset = {
x: clamp(
currentDragState.originX + event.clientX - currentDragState.pointerX,
currentDragState.originX + clientX - currentDragState.pointerX,
currentDragState.minX,
currentDragState.maxX,
),
y: clamp(
currentDragState.originY + event.clientY - currentDragState.pointerY,
currentDragState.originY + clientY - currentDragState.pointerY,
currentDragState.minY,
currentDragState.maxY,
),
};
const dropTarget = getDropTarget(
event.clientX,
event.clientY,
currentDragState.cardId,
);
const dropTarget = getDropTarget(clientX, clientY, currentDragState.cardId);
if (dropTarget !== null) {
captureCardAnimationRects(currentDragState.cardId);
@@ -1442,14 +1487,72 @@ export function FamiliarInterfaceVisual({
setFloatingPosition(releasedOffset);
}
dragStateRef.current = null;
setDraggedCardId(null);
setIsDragging(false);
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
clearDragState();
};
const handleCapturedPointerMove = (
event: ReactPointerEvent<HTMLDivElement>,
) => {
if (event.pointerId !== activePointerIdRef.current) {
return;
}
event.preventDefault();
updateDragOffsetFromPointer(event.clientX, event.clientY);
};
const handleCapturedPointerUp = (
event: ReactPointerEvent<HTMLDivElement>,
) => {
if (event.pointerId !== activePointerIdRef.current) {
return;
}
event.preventDefault();
finishDragAtPosition(event.clientX, event.clientY);
releaseActivePointerCapture();
};
const handleCapturedPointerCancel = (
event: ReactPointerEvent<HTMLDivElement>,
) => {
if (event.pointerId !== activePointerIdRef.current) {
return;
}
event.preventDefault();
const currentDragState = dragStateRef.current;
if (currentDragState !== null) {
finishDragAtPosition(
currentDragState.lastClientX,
currentDragState.lastClientY,
);
}
releaseActivePointerCapture();
};
const handleLostPointerCapture = () => {
const currentDragState = dragStateRef.current;
if (currentDragState !== null) {
finishDragAtPosition(
currentDragState.lastClientX,
currentDragState.lastClientY,
);
}
activePointerIdRef.current = null;
};
useEffect(() => {
return () => {
releaseActivePointerCapture();
};
}, []);
const showHandCursor =
!hasDraggedCard && !isDragging && activeCardId === null;
@@ -1468,9 +1571,6 @@ export function FamiliarInterfaceVisual({
onPointerDown={(event) => {
handlePointerDown(event, cardId, { laneIndex, type: 'lane' });
}}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
style={isDraggedCard ? { visibility: 'hidden' } : undefined}
>
<OpportunityPreviewCard
@@ -1482,9 +1582,18 @@ export function FamiliarInterfaceVisual({
};
return (
<VisualRoot aria-hidden="true">
<VisualRoot aria-hidden="true" ref={rootRef}>
<SceneViewport>
<SceneFrame ref={sceneFrameRef}>
<SceneBackdrop
$backgroundImageRotationDeg={backgroundImageRotationDeg}
>
<FamiliarInterfaceGradientBackdrop
active={active}
imageUrl={backgroundImageSrc}
pointerTargetRef={pointerTargetRef ?? rootRef}
/>
</SceneBackdrop>
<BoardGroup $active={active}>
<BoardSurface>
<BoardTitleRow>
@@ -1559,7 +1668,13 @@ export function FamiliarInterfaceVisual({
</ColumnsGrid>
</BoardSurface>
<InteractionLayer ref={interactionLayerRef}>
<InteractionLayer
ref={interactionLayerRef}
onLostPointerCapture={handleLostPointerCapture}
onPointerCancel={handleCapturedPointerCancel}
onPointerMove={handleCapturedPointerMove}
onPointerUp={handleCapturedPointerUp}
>
{showHandCursor ? (
<DragCursor $active={active}>
<DragCursorInner $active={active}>
@@ -1578,9 +1693,6 @@ export function FamiliarInterfaceVisual({
type: 'floating',
});
}}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
style={{
transform: `translate3d(${floatingPosition.x}px, ${floatingPosition.y}px, 0)`,
visibility:
@@ -0,0 +1,924 @@
'use client';
import { styled } from '@linaria/react';
import { type RefObject, useEffect, useRef } from 'react';
import * as THREE from 'three';
const DEFAULT_IMAGE_URL =
'/images/home/three-cards-feature/fast-path-gradient.webp';
const VIRTUAL_RENDER_HEIGHT = 1200;
const REFERENCE_PREVIEW_DISTANCE = 4;
const PREVIEW_DISTANCE = REFERENCE_PREVIEW_DISTANCE;
const MIN_FOOTPRINT_SCALE = 0.001;
const HALFTONE_SCALE = 28;
const HALFTONE_POWER = 0.3;
const HALFTONE_WIDTH = 0.3;
const IMAGE_CONTRAST = 1;
const DASH_COLOR = '#777';
const HOVER_DASH_COLOR = '#777';
const HOVER_LIGHT_INTENSITY = 0.85;
const HOVER_LIGHT_RADIUS = 0.6;
const HOVER_HALFTONE_RADIUS = 0.45;
const IMAGE_POINTER_FOLLOW = 0.38;
const IMAGE_POINTER_VELOCITY_DAMPING = 0.82;
const IMAGE_HOVER_FADE_IN = 18;
const IMAGE_HOVER_FADE_OUT = 7;
export type HalftoneBackdropConfig = {
activeHoverX?: number;
activeHoverY?: number;
dashColor?: string;
halftonePower?: number;
halftoneScale?: number;
halftoneWidth?: number;
hoverDashColor?: string;
hoverHalftoneRadius?: number;
hoverLightIntensity?: number;
hoverLightRadius?: number;
imageContrast?: number;
previewDistance?: number;
};
type ResolvedHalftoneBackdropConfig = Required<HalftoneBackdropConfig>;
const DEFAULT_HALFTONE_BACKDROP_CONFIG: ResolvedHalftoneBackdropConfig = {
activeHoverX: 0.16,
activeHoverY: 0.46,
dashColor: DASH_COLOR,
halftonePower: HALFTONE_POWER,
halftoneScale: HALFTONE_SCALE,
halftoneWidth: HALFTONE_WIDTH,
hoverDashColor: HOVER_DASH_COLOR,
hoverHalftoneRadius: HOVER_HALFTONE_RADIUS,
hoverLightIntensity: HOVER_LIGHT_INTENSITY,
hoverLightRadius: HOVER_LIGHT_RADIUS,
imageContrast: IMAGE_CONTRAST,
previewDistance: PREVIEW_DISTANCE,
};
const passThroughVertexShader = `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = vec4(position, 1.0);
}
`;
const blurFragmentShader = `
precision highp float;
uniform sampler2D tInput;
uniform vec2 dir;
uniform vec2 res;
varying vec2 vUv;
void main() {
vec4 sum = vec4(0.0);
vec2 px = dir / res;
float w[5];
w[0] = 0.227027;
w[1] = 0.1945946;
w[2] = 0.1216216;
w[3] = 0.054054;
w[4] = 0.016216;
sum += texture2D(tInput, vUv) * w[0];
for (int i = 1; i < 5; i++) {
float fi = float(i) * 3.0;
sum += texture2D(tInput, vUv + px * fi) * w[i];
sum += texture2D(tInput, vUv - px * fi) * w[i];
}
gl_FragColor = sum;
}
`;
const halftoneFragmentShader = `
precision highp float;
uniform sampler2D tScene;
uniform vec2 effectResolution;
uniform vec2 logicalResolution;
uniform float tile;
uniform float s_3;
uniform float s_4;
uniform vec3 dashColor;
uniform vec3 hoverDashColor;
uniform float footprintScale;
uniform vec2 interactionUv;
uniform vec2 interactionVelocity;
uniform float hoverLightStrength;
uniform float hoverLightRadius;
varying vec2 vUv;
float distSegment(in vec2 p, in vec2 a, in vec2 b) {
vec2 pa = p - a;
vec2 ba = b - a;
float denom = max(dot(ba, ba), 0.000001);
float h = clamp(dot(pa, ba) / denom, 0.0, 1.0);
return length(pa - ba * h);
}
float lineSimpleEt(in vec2 p, in float r, in float thickness) {
vec2 a = vec2(0.5) + vec2(-r, 0.0);
vec2 b = vec2(0.5) + vec2(r, 0.0);
float distToSegment = distSegment(p, a, b);
float halfThickness = thickness * r;
return distToSegment - halfThickness;
}
void main() {
vec2 fragCoord =
(gl_FragCoord.xy / max(effectResolution, vec2(1.0))) * logicalResolution;
float halftoneSize = max(tile * max(footprintScale, 0.001), 1.0);
vec2 pointerPx = interactionUv * logicalResolution;
vec2 fragDelta = fragCoord - pointerPx;
float fragDist = length(fragDelta);
vec2 radialDir = fragDist > 0.001 ? fragDelta / fragDist : vec2(0.0, 1.0);
float velocityMagnitude = length(interactionVelocity);
vec2 motionDir = velocityMagnitude > 0.001
? interactionVelocity / velocityMagnitude
: vec2(0.0, 0.0);
float motionBias = velocityMagnitude > 0.001
? dot(-radialDir, motionDir) * 0.5 + 0.5
: 0.5;
float hoverLightMask = 0.0;
if (hoverLightStrength > 0.0) {
float lightRadiusPx = hoverLightRadius * logicalResolution.y;
hoverLightMask = smoothstep(lightRadiusPx, 0.0, fragDist);
}
vec2 effectCoord = fragCoord;
vec2 cellIndex = floor(effectCoord / halftoneSize);
vec2 sampleUv = clamp(
(cellIndex + 0.5) * halftoneSize / logicalResolution,
vec2(0.0),
vec2(1.0)
);
vec2 cellUv = fract(effectCoord / halftoneSize);
vec4 sceneSample = texture2D(tScene, sampleUv);
float mask = smoothstep(0.02, 0.08, sceneSample.a);
float lightLift =
hoverLightStrength * hoverLightMask * mix(0.78, 1.18, motionBias) * 0.22;
float bandRadius = clamp(
(
(
sceneSample.r +
sceneSample.g +
sceneSample.b +
s_3 * length(vec2(0.5))
) *
(1.0 / 3.0)
) + lightLift,
0.0,
1.0
) * 1.86 * 0.5;
float alpha = 0.0;
if (bandRadius > 0.0001) {
float signedDistance = lineSimpleEt(cellUv, bandRadius, s_4);
float edge = 0.02;
alpha = (1.0 - smoothstep(0.0, edge, signedDistance)) * mask;
}
float hoverStrength = clamp(hoverLightMask * hoverLightStrength, 0.0, 1.0);
vec3 activeDashColor = mix(dashColor, hoverDashColor, hoverStrength);
vec3 color = activeDashColor * alpha;
gl_FragColor = vec4(color, alpha);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}
`;
const imagePassthroughFragmentShader = `
precision highp float;
uniform sampler2D tImage;
uniform vec2 imageSize;
uniform vec2 viewportSize;
uniform float zoom;
uniform float contrast;
varying vec2 vUv;
void main() {
float imageAspect = imageSize.x / imageSize.y;
float viewAspect = viewportSize.x / viewportSize.y;
vec2 uv = vUv;
if (imageAspect > viewAspect) {
float scale = viewAspect / imageAspect;
uv.x = (uv.x - 0.5) * scale + 0.5;
} else {
float scale = imageAspect / viewAspect;
uv.y = (uv.y - 0.5) * scale + 0.5;
}
uv = (uv - 0.5) / zoom + 0.5;
uv.y = 1.0 - uv.y;
float inBounds = step(0.0, uv.x) * step(uv.x, 1.0)
* step(0.0, uv.y) * step(uv.y, 1.0);
vec4 color = texture2D(tImage, clamp(uv, 0.0, 1.0));
vec3 contrastColor = clamp((color.rgb - 0.5) * contrast + 0.5, 0.0, 1.0);
gl_FragColor = vec4(contrastColor, inBounds);
}
`;
type Rect = {
x: number;
y: number;
width: number;
height: number;
};
type InteractionState = {
hoverStrength: number;
mouseX: number;
mouseY: number;
pointerInside: boolean;
pointerVelocityX: number;
pointerVelocityY: number;
smoothedMouseX: number;
smoothedMouseY: number;
};
function clampRectToViewport(
rect: Rect,
viewportWidth: number,
viewportHeight: number,
) {
const minX = Math.max(rect.x, 0);
const minY = Math.max(rect.y, 0);
const maxX = Math.min(rect.x + rect.width, viewportWidth);
const maxY = Math.min(rect.y + rect.height, viewportHeight);
if (maxX <= minX || maxY <= minY) {
return null;
}
return {
x: minX,
y: minY,
width: maxX - minX,
height: maxY - minY,
};
}
function getRectArea(rect: Rect | null) {
if (!rect) {
return 0;
}
return Math.max(rect.width, 0) * Math.max(rect.height, 0);
}
function getImagePreviewZoom(previewDistance: number) {
return REFERENCE_PREVIEW_DISTANCE / Math.max(previewDistance, 0.001);
}
function getContainedImageRect({
imageHeight,
imageWidth,
viewportHeight,
viewportWidth,
zoom,
}: {
imageHeight: number;
imageWidth: number;
viewportHeight: number;
viewportWidth: number;
zoom: number;
}) {
if (
imageWidth <= 0 ||
imageHeight <= 0 ||
viewportWidth <= 0 ||
viewportHeight <= 0
) {
return null;
}
const imageAspect = imageWidth / imageHeight;
const viewAspect = viewportWidth / viewportHeight;
let fittedWidth = viewportWidth;
let fittedHeight = viewportHeight;
if (imageAspect > viewAspect) {
fittedHeight = viewportWidth / imageAspect;
} else {
fittedWidth = viewportHeight * imageAspect;
}
const scaledWidth = fittedWidth * zoom;
const scaledHeight = fittedHeight * zoom;
return clampRectToViewport(
{
x: (viewportWidth - scaledWidth) * 0.5,
y: (viewportHeight - scaledHeight) * 0.5,
width: scaledWidth,
height: scaledHeight,
},
viewportWidth,
viewportHeight,
);
}
function getFootprintScaleFromRects(
currentRect: Rect | null,
referenceRect: Rect | null,
) {
const currentArea = getRectArea(currentRect);
const referenceArea = getRectArea(referenceRect);
if (currentArea <= 0 || referenceArea <= 0) {
return 1;
}
return Math.max(
Math.sqrt(currentArea / referenceArea),
MIN_FOOTPRINT_SCALE,
);
}
function getImageFootprintScale({
imageHeight,
imageWidth,
previewDistance,
viewportHeight,
viewportWidth,
}: {
imageHeight: number;
imageWidth: number;
previewDistance: number;
viewportHeight: number;
viewportWidth: number;
}) {
const currentRect = getContainedImageRect({
imageHeight,
imageWidth,
viewportHeight,
viewportWidth,
zoom: getImagePreviewZoom(previewDistance),
});
const referenceRect = getContainedImageRect({
imageHeight,
imageWidth,
viewportHeight,
viewportWidth,
zoom: 1,
});
return getFootprintScaleFromRects(currentRect, referenceRect);
}
function createRenderTarget(width: number, height: number) {
return new THREE.WebGLRenderTarget(width, height, {
minFilter: THREE.LinearFilter,
magFilter: THREE.LinearFilter,
format: THREE.RGBAFormat,
});
}
function createInteractionState(): InteractionState {
return {
hoverStrength: 0,
mouseX: 0.5,
mouseY: 0.5,
pointerInside: false,
pointerVelocityX: 0,
pointerVelocityY: 0,
smoothedMouseX: 0.5,
smoothedMouseY: 0.5,
};
}
function loadImage(imageUrl: string) {
return new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error(`Failed to load image: ${imageUrl}`));
image.src = imageUrl;
});
}
async function mountFastPathGradient({
container,
config,
imageUrl,
isExternallyActive,
pointerTarget,
}: {
container: HTMLDivElement;
config: ResolvedHalftoneBackdropConfig;
imageUrl: string;
isExternallyActive: () => boolean;
pointerTarget?: HTMLElement | null;
}) {
const HOVER_STRENGTH_STOP_EPSILON = 0.001;
const POINTER_POSITION_STOP_EPSILON = 0.001;
const POINTER_VELOCITY_STOP_EPSILON = 0.0001;
const image = await loadImage(imageUrl);
const getWidth = () => Math.max(container.clientWidth, 1);
const getHeight = () => Math.max(container.clientHeight, 1);
const getVirtualHeight = () => Math.max(VIRTUAL_RENDER_HEIGHT, getHeight());
const getVirtualWidth = () =>
Math.max(
Math.round(getVirtualHeight() * (getWidth() / Math.max(getHeight(), 1))),
1,
);
const renderer = new THREE.WebGLRenderer({ antialias: false, alpha: true });
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.setPixelRatio(1);
renderer.setClearColor(0x000000, 0);
renderer.setSize(getVirtualWidth(), getVirtualHeight(), false);
const canvas = renderer.domElement;
canvas.style.cursor = 'default';
canvas.style.display = 'block';
canvas.style.height = '100%';
canvas.style.touchAction = 'none';
canvas.style.width = '100%';
container.appendChild(canvas);
const trackingElement = pointerTarget ?? canvas;
const imageTexture = new THREE.Texture(image);
imageTexture.colorSpace = THREE.SRGBColorSpace;
imageTexture.needsUpdate = true;
const sceneTarget = createRenderTarget(getVirtualWidth(), getVirtualHeight());
const blurTargetA = createRenderTarget(getVirtualWidth(), getVirtualHeight());
const blurTargetB = createRenderTarget(getVirtualWidth(), getVirtualHeight());
const fullScreenGeometry = new THREE.PlaneGeometry(2, 2);
const orthographicCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
const imageMaterial = new THREE.ShaderMaterial({
uniforms: {
contrast: { value: config.imageContrast },
imageSize: { value: new THREE.Vector2(image.width, image.height) },
tImage: { value: imageTexture },
viewportSize: {
value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
},
zoom: { value: getImagePreviewZoom(config.previewDistance) },
},
fragmentShader: imagePassthroughFragmentShader,
vertexShader: passThroughVertexShader,
});
const blurHorizontalMaterial = new THREE.ShaderMaterial({
uniforms: {
dir: { value: new THREE.Vector2(1, 0) },
res: { value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()) },
tInput: { value: null },
},
fragmentShader: blurFragmentShader,
vertexShader: passThroughVertexShader,
});
const blurVerticalMaterial = new THREE.ShaderMaterial({
uniforms: {
dir: { value: new THREE.Vector2(0, 1) },
res: { value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()) },
tInput: { value: null },
},
fragmentShader: blurFragmentShader,
vertexShader: passThroughVertexShader,
});
const halftoneMaterial = new THREE.ShaderMaterial({
fragmentShader: halftoneFragmentShader,
transparent: true,
uniforms: {
dashColor: { value: new THREE.Color(config.dashColor) },
effectResolution: {
value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
},
footprintScale: { value: 1 },
hoverDashColor: { value: new THREE.Color(config.hoverDashColor) },
hoverLightRadius: { value: config.hoverLightRadius },
hoverLightStrength: { value: 0 },
interactionUv: { value: new THREE.Vector2(0.5, 0.5) },
interactionVelocity: { value: new THREE.Vector2(0, 0) },
logicalResolution: {
value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
},
s_3: { value: config.halftonePower },
s_4: { value: config.halftoneWidth },
tScene: { value: sceneTarget.texture },
tile: { value: config.halftoneScale },
},
vertexShader: passThroughVertexShader,
});
const fullScreenMesh = new THREE.Mesh(fullScreenGeometry, imageMaterial);
const blurHorizontalMesh = new THREE.Mesh(
fullScreenGeometry,
blurHorizontalMaterial,
);
const blurVerticalMesh = new THREE.Mesh(
fullScreenGeometry,
blurVerticalMaterial,
);
const postProcessMesh = new THREE.Mesh(fullScreenGeometry, halftoneMaterial);
const imageScene = new THREE.Scene();
imageScene.add(fullScreenMesh);
const blurHorizontalScene = new THREE.Scene();
blurHorizontalScene.add(blurHorizontalMesh);
const blurVerticalScene = new THREE.Scene();
blurVerticalScene.add(blurVerticalMesh);
const postScene = new THREE.Scene();
postScene.add(postProcessMesh);
const updateViewportUniforms = (
logicalWidth: number,
logicalHeight: number,
effectWidth: number,
effectHeight: number,
) => {
blurHorizontalMaterial.uniforms.res.value.set(effectWidth, effectHeight);
blurVerticalMaterial.uniforms.res.value.set(effectWidth, effectHeight);
halftoneMaterial.uniforms.effectResolution.value.set(
effectWidth,
effectHeight,
);
halftoneMaterial.uniforms.logicalResolution.value.set(
logicalWidth,
logicalHeight,
);
imageMaterial.uniforms.viewportSize.value.set(logicalWidth, logicalHeight);
};
const getHalftoneScale = () =>
getImageFootprintScale({
imageHeight: image.height,
imageWidth: image.width,
previewDistance: config.previewDistance,
viewportHeight: getVirtualHeight(),
viewportWidth: getVirtualWidth(),
});
const interaction = createInteractionState();
let animationFrameId: number | null = null;
let lastFrameTime: number | null = null;
const renderBackdrop = (deltaSeconds: number) => {
const externalActive = isExternallyActive();
const isHoverActive = externalActive || interaction.pointerInside;
const targetHoverStrength = isHoverActive ? 1 : 0;
const hoverEasing =
1 -
Math.exp(
-deltaSeconds *
(isHoverActive ? IMAGE_HOVER_FADE_IN : IMAGE_HOVER_FADE_OUT),
);
interaction.hoverStrength +=
(targetHoverStrength - interaction.hoverStrength) * hoverEasing;
if (
Math.abs(targetHoverStrength - interaction.hoverStrength) <=
HOVER_STRENGTH_STOP_EPSILON
) {
interaction.hoverStrength = targetHoverStrength;
}
const targetMouseX =
externalActive && !interaction.pointerInside
? config.activeHoverX
: interaction.mouseX;
const targetMouseY =
externalActive && !interaction.pointerInside
? config.activeHoverY
: interaction.mouseY;
interaction.smoothedMouseX +=
(targetMouseX - interaction.smoothedMouseX) * IMAGE_POINTER_FOLLOW;
interaction.smoothedMouseY +=
(targetMouseY - interaction.smoothedMouseY) * IMAGE_POINTER_FOLLOW;
if (
Math.abs(targetMouseX - interaction.smoothedMouseX) <=
POINTER_POSITION_STOP_EPSILON
) {
interaction.smoothedMouseX = targetMouseX;
}
if (
Math.abs(targetMouseY - interaction.smoothedMouseY) <=
POINTER_POSITION_STOP_EPSILON
) {
interaction.smoothedMouseY = targetMouseY;
}
interaction.pointerVelocityX *= IMAGE_POINTER_VELOCITY_DAMPING;
interaction.pointerVelocityY *= IMAGE_POINTER_VELOCITY_DAMPING;
if (
Math.abs(interaction.pointerVelocityX) <= POINTER_VELOCITY_STOP_EPSILON
) {
interaction.pointerVelocityX = 0;
}
if (
Math.abs(interaction.pointerVelocityY) <= POINTER_VELOCITY_STOP_EPSILON
) {
interaction.pointerVelocityY = 0;
}
halftoneMaterial.uniforms.interactionUv.value.set(
interaction.smoothedMouseX,
1 - interaction.smoothedMouseY,
);
halftoneMaterial.uniforms.interactionVelocity.value.set(
interaction.pointerVelocityX * getVirtualWidth(),
-interaction.pointerVelocityY * getVirtualHeight(),
);
halftoneMaterial.uniforms.hoverLightStrength.value =
config.hoverLightIntensity * interaction.hoverStrength;
halftoneMaterial.uniforms.hoverLightRadius.value =
config.hoverHalftoneRadius + config.hoverLightRadius * 0.5;
halftoneMaterial.uniforms.footprintScale.value = getHalftoneScale();
imageMaterial.uniforms.zoom.value = getImagePreviewZoom(
config.previewDistance,
);
renderer.setRenderTarget(sceneTarget);
renderer.render(imageScene, orthographicCamera);
blurHorizontalMaterial.uniforms.tInput.value = sceneTarget.texture;
renderer.setRenderTarget(blurTargetA);
renderer.render(blurHorizontalScene, orthographicCamera);
blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture;
renderer.setRenderTarget(blurTargetB);
renderer.render(blurVerticalScene, orthographicCamera);
blurHorizontalMaterial.uniforms.tInput.value = blurTargetB.texture;
renderer.setRenderTarget(blurTargetA);
renderer.render(blurHorizontalScene, orthographicCamera);
blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture;
renderer.setRenderTarget(blurTargetB);
renderer.render(blurVerticalScene, orthographicCamera);
renderer.setRenderTarget(null);
renderer.clear();
renderer.render(postScene, orthographicCamera);
return (
isHoverActive ||
interaction.hoverStrength !== targetHoverStrength ||
interaction.smoothedMouseX !== targetMouseX ||
interaction.smoothedMouseY !== targetMouseY ||
interaction.pointerVelocityX !== 0 ||
interaction.pointerVelocityY !== 0
);
};
const runFrame = (timestamp: number) => {
animationFrameId = null;
const deltaSeconds =
lastFrameTime === null
? 1 / 60
: Math.min((timestamp - lastFrameTime) / 1000, 0.1);
lastFrameTime = timestamp;
if (renderBackdrop(deltaSeconds)) {
animationFrameId = window.requestAnimationFrame(runFrame);
return;
}
lastFrameTime = null;
};
const ensureAnimationLoop = () => {
if (animationFrameId !== null) {
return;
}
animationFrameId = window.requestAnimationFrame(runFrame);
};
const syncSize = () => {
const virtualWidth = getVirtualWidth();
const virtualHeight = getVirtualHeight();
renderer.setSize(virtualWidth, virtualHeight, false);
sceneTarget.setSize(virtualWidth, virtualHeight);
blurTargetA.setSize(virtualWidth, virtualHeight);
blurTargetB.setSize(virtualWidth, virtualHeight);
updateViewportUniforms(
virtualWidth,
virtualHeight,
virtualWidth,
virtualHeight,
);
if (animationFrameId === null) {
renderBackdrop(0);
}
};
const resizeObserver = new ResizeObserver(syncSize);
resizeObserver.observe(container);
syncSize();
const updatePointerPosition = (
event: PointerEvent,
options?: { resetVelocity?: boolean },
) => {
const rect = trackingElement.getBoundingClientRect();
const width = Math.max(rect.width, 1);
const height = Math.max(rect.height, 1);
const nextMouseX = THREE.MathUtils.clamp(
(event.clientX - rect.left) / width,
0,
1,
);
const nextMouseY = THREE.MathUtils.clamp(
(event.clientY - rect.top) / height,
0,
1,
);
const deltaX = nextMouseX - interaction.mouseX;
const deltaY = nextMouseY - interaction.mouseY;
interaction.mouseX = nextMouseX;
interaction.mouseY = nextMouseY;
interaction.pointerInside =
event.clientX >= rect.left &&
event.clientX <= rect.right &&
event.clientY >= rect.top &&
event.clientY <= rect.bottom;
if (options?.resetVelocity) {
interaction.pointerVelocityX = 0;
interaction.pointerVelocityY = 0;
interaction.smoothedMouseX = nextMouseX;
interaction.smoothedMouseY = nextMouseY;
return;
}
interaction.pointerVelocityX = deltaX;
interaction.pointerVelocityY = deltaY;
};
const handlePointerMove = (event: PointerEvent) => {
const shouldReset = !interaction.pointerInside;
updatePointerPosition(
event,
shouldReset ? { resetVelocity: true } : undefined,
);
ensureAnimationLoop();
};
const handlePointerLeave = () => {
interaction.pointerInside = false;
interaction.pointerVelocityX = 0;
interaction.pointerVelocityY = 0;
ensureAnimationLoop();
};
trackingElement.addEventListener('pointermove', handlePointerMove);
trackingElement.addEventListener('pointerleave', handlePointerLeave);
if (isExternallyActive()) {
ensureAnimationLoop();
}
return {
dispose: () => {
if (animationFrameId !== null) {
window.cancelAnimationFrame(animationFrameId);
}
resizeObserver.disconnect();
trackingElement.removeEventListener('pointermove', handlePointerMove);
trackingElement.removeEventListener('pointerleave', handlePointerLeave);
blurHorizontalMaterial.dispose();
blurVerticalMaterial.dispose();
halftoneMaterial.dispose();
imageMaterial.dispose();
imageTexture.dispose();
fullScreenGeometry.dispose();
sceneTarget.dispose();
blurTargetA.dispose();
blurTargetB.dispose();
renderer.dispose();
if (canvas.parentNode === container) {
container.removeChild(canvas);
}
},
wake: ensureAnimationLoop,
};
}
const VisualMount = styled.div`
background: transparent;
display: block;
height: 100%;
min-width: 0;
width: 100%;
`;
type FastPathGradientBackdropProps = {
active?: boolean;
config?: HalftoneBackdropConfig;
imageUrl?: string;
pointerTargetRef?: RefObject<HTMLElement | null>;
};
export function FastPathGradientBackdrop({
active = false,
config,
imageUrl = DEFAULT_IMAGE_URL,
pointerTargetRef,
}: FastPathGradientBackdropProps) {
const mountRef = useRef<HTMLDivElement>(null);
const activeRef = useRef(active);
const wakeRef = useRef<VoidFunction | null>(null);
const resolvedConfig = {
...DEFAULT_HALFTONE_BACKDROP_CONFIG,
...config,
};
activeRef.current = active;
useEffect(() => {
const container = mountRef.current;
if (!container) {
return;
}
let dispose: VoidFunction | undefined;
let cancelled = false;
void mountFastPathGradient({
config: resolvedConfig,
container,
imageUrl,
isExternallyActive: () => activeRef.current,
pointerTarget: pointerTargetRef?.current,
})
.then((mountedGradient) => {
if (cancelled) {
mountedGradient.dispose();
return;
}
wakeRef.current = mountedGradient.wake;
dispose = mountedGradient.dispose;
})
.catch((error) => {
console.error(error);
});
return () => {
cancelled = true;
wakeRef.current = null;
dispose?.();
};
}, [
imageUrl,
resolvedConfig.dashColor,
resolvedConfig.halftonePower,
resolvedConfig.halftoneScale,
resolvedConfig.halftoneWidth,
resolvedConfig.hoverDashColor,
resolvedConfig.hoverHalftoneRadius,
resolvedConfig.hoverLightIntensity,
resolvedConfig.hoverLightRadius,
resolvedConfig.imageContrast,
resolvedConfig.previewDistance,
]);
useEffect(() => {
wakeRef.current?.();
}, [active]);
return <VisualMount aria-hidden ref={mountRef} />;
}
@@ -7,7 +7,7 @@ import { theme } from '@/theme';
import { styled } from '@linaria/react';
import { IconUsersGroup } from '@tabler/icons-react';
import Image from 'next/image';
import { type ReactNode, useState } from 'react';
import { type ReactNode, useRef, useState } from 'react';
import { FastPathVisual } from './FastPathVisual';
import { FamiliarInterfaceVisual } from './FamiliarInterfaceVisual';
import { LiveDataVisual } from './LiveDataVisual';
@@ -16,7 +16,7 @@ const FeatureCardContainer = styled.div`
display: grid;
grid-template-columns: 1fr;
grid-template-rows: auto auto;
background-color: ${theme.colors.primary.background[100]};
background-color: ${theme.colors.secondary.background[5]};
border: 1px solid ${theme.colors.primary.border[20]};
border-radius: ${theme.radius(2)};
overflow: hidden;
@@ -26,18 +26,26 @@ const FeatureCardContainer = styled.div`
const CardImage = styled.div`
width: 100%;
height: 508px;
height: 524px;
box-sizing: border-box;
padding: 8px 8px 0;
`;
const CardImageFrame = styled.div`
background-color: ${theme.colors.primary.border[10]};
position: relative;
border-radius: 2px;
height: 100%;
isolation: isolate;
overflow: hidden;
position: relative;
width: 100%;
`;
const CardContent = styled.div`
background-color: ${theme.colors.secondary.background[5]};
display: grid;
grid-template-columns: 1fr;
row-gap: ${theme.spacing(3)};
padding: ${theme.spacing(3.5)} ${theme.spacing(4)} ${theme.spacing(4)};
row-gap: 8px;
padding: ${theme.spacing(3)} ${theme.spacing(4)} ${theme.spacing(4)};
`;
const CardTitleRow = styled.div`
@@ -111,18 +119,40 @@ function renderFeatureCardIcon(icon: ThreeCardsFeatureCardType['icon']) {
export function FeatureCard({ featureCard }: FeatureCardProps) {
const [isHovered, setIsHovered] = useState(false);
const imageFrameRef = useRef<HTMLDivElement>(null);
let visual: ReactNode;
if ('illustration' in featureCard) {
switch (featureCard.illustration) {
case 'familiar-interface':
visual = <FamiliarInterfaceVisual active={isHovered} />;
visual = (
<FamiliarInterfaceVisual
active={isHovered}
backgroundImageRotationDeg={featureCard.backgroundImageRotationDeg}
backgroundImageSrc={featureCard.backgroundImageSrc}
pointerTargetRef={imageFrameRef}
/>
);
break;
case 'fast-path':
visual = <FastPathVisual />;
visual = (
<FastPathVisual
active={isHovered}
backgroundImageRotationDeg={featureCard.backgroundImageRotationDeg}
backgroundImageSrc={featureCard.backgroundImageSrc}
pointerTargetRef={imageFrameRef}
/>
);
break;
case 'live-data':
visual = <LiveDataVisual active={isHovered} />;
visual = (
<LiveDataVisual
active={isHovered}
backgroundImageRotationDeg={featureCard.backgroundImageRotationDeg}
backgroundImageSrc={featureCard.backgroundImageSrc}
pointerTargetRef={imageFrameRef}
/>
);
break;
}
} else {
@@ -145,7 +175,9 @@ export function FeatureCard({ featureCard }: FeatureCardProps) {
setIsHovered(false);
}}
>
<CardImage>{visual}</CardImage>
<CardImage>
<CardImageFrame ref={imageFrameRef}>{visual}</CardImageFrame>
</CardImage>
<CardContent>
<CardTitleRow>
<CardIcon>{renderFeatureCardIcon(featureCard.icon)}</CardIcon>
@@ -0,0 +1,923 @@
'use client';
import { styled } from '@linaria/react';
import { type RefObject, useEffect, useRef } from 'react';
import * as THREE from 'three';
const DEFAULT_IMAGE_URL =
'/images/home/three-cards-feature/live-data-gradient.webp';
const VIRTUAL_RENDER_HEIGHT = 1200;
const REFERENCE_PREVIEW_DISTANCE = 4;
const PREVIEW_DISTANCE = REFERENCE_PREVIEW_DISTANCE;
const MIN_FOOTPRINT_SCALE = 0.001;
const HALFTONE_SCALE = 28;
const HALFTONE_POWER = -0.2;
const HALFTONE_WIDTH = 0.4;
const IMAGE_CONTRAST = 1;
const DASH_COLOR = '#777';
const HOVER_DASH_COLOR = '#777';
const HOVER_LIGHT_INTENSITY = 0.85;
const HOVER_LIGHT_RADIUS = 0.6;
const HOVER_HALFTONE_RADIUS = 0.45;
const IMAGE_POINTER_FOLLOW = 0.38;
const IMAGE_POINTER_VELOCITY_DAMPING = 0.82;
const IMAGE_HOVER_FADE_IN = 18;
const IMAGE_HOVER_FADE_OUT = 7;
export type HalftoneBackdropConfig = {
activeHoverX?: number;
activeHoverY?: number;
dashColor?: string;
halftonePower?: number;
halftoneScale?: number;
halftoneWidth?: number;
hoverDashColor?: string;
hoverHalftoneRadius?: number;
hoverLightIntensity?: number;
hoverLightRadius?: number;
imageContrast?: number;
previewDistance?: number;
};
type ResolvedHalftoneBackdropConfig = Required<HalftoneBackdropConfig>;
const DEFAULT_HALFTONE_BACKDROP_CONFIG: ResolvedHalftoneBackdropConfig = {
activeHoverX: 0.16,
activeHoverY: 0.46,
dashColor: DASH_COLOR,
halftonePower: HALFTONE_POWER,
halftoneScale: HALFTONE_SCALE,
halftoneWidth: HALFTONE_WIDTH,
hoverDashColor: HOVER_DASH_COLOR,
hoverHalftoneRadius: HOVER_HALFTONE_RADIUS,
hoverLightIntensity: HOVER_LIGHT_INTENSITY,
hoverLightRadius: HOVER_LIGHT_RADIUS,
imageContrast: IMAGE_CONTRAST,
previewDistance: PREVIEW_DISTANCE,
};
const passThroughVertexShader = `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = vec4(position, 1.0);
}
`;
const blurFragmentShader = `
precision highp float;
uniform sampler2D tInput;
uniform vec2 dir;
uniform vec2 res;
varying vec2 vUv;
void main() {
vec4 sum = vec4(0.0);
vec2 px = dir / res;
float w[5];
w[0] = 0.227027;
w[1] = 0.1945946;
w[2] = 0.1216216;
w[3] = 0.054054;
w[4] = 0.016216;
sum += texture2D(tInput, vUv) * w[0];
for (int i = 1; i < 5; i++) {
float fi = float(i) * 3.0;
sum += texture2D(tInput, vUv + px * fi) * w[i];
sum += texture2D(tInput, vUv - px * fi) * w[i];
}
gl_FragColor = sum;
}
`;
const halftoneFragmentShader = `
precision highp float;
uniform sampler2D tScene;
uniform vec2 effectResolution;
uniform vec2 logicalResolution;
uniform float tile;
uniform float s_3;
uniform float s_4;
uniform vec3 dashColor;
uniform vec3 hoverDashColor;
uniform float footprintScale;
uniform vec2 interactionUv;
uniform vec2 interactionVelocity;
uniform float hoverLightStrength;
uniform float hoverLightRadius;
varying vec2 vUv;
float distSegment(in vec2 p, in vec2 a, in vec2 b) {
vec2 pa = p - a;
vec2 ba = b - a;
float denom = max(dot(ba, ba), 0.000001);
float h = clamp(dot(pa, ba) / denom, 0.0, 1.0);
return length(pa - ba * h);
}
float lineSimpleEt(in vec2 p, in float r, in float thickness) {
vec2 a = vec2(0.5) + vec2(-r, 0.0);
vec2 b = vec2(0.5) + vec2(r, 0.0);
float distToSegment = distSegment(p, a, b);
float halfThickness = thickness * r;
return distToSegment - halfThickness;
}
void main() {
vec2 fragCoord =
(gl_FragCoord.xy / max(effectResolution, vec2(1.0))) * logicalResolution;
float halftoneSize = max(tile * max(footprintScale, 0.001), 1.0);
vec2 pointerPx = interactionUv * logicalResolution;
vec2 fragDelta = fragCoord - pointerPx;
float fragDist = length(fragDelta);
vec2 radialDir = fragDist > 0.001 ? fragDelta / fragDist : vec2(0.0, 1.0);
float velocityMagnitude = length(interactionVelocity);
vec2 motionDir = velocityMagnitude > 0.001
? interactionVelocity / velocityMagnitude
: vec2(0.0, 0.0);
float motionBias = velocityMagnitude > 0.001
? dot(-radialDir, motionDir) * 0.5 + 0.5
: 0.5;
float hoverLightMask = 0.0;
if (hoverLightStrength > 0.0) {
float lightRadiusPx = hoverLightRadius * logicalResolution.y;
hoverLightMask = smoothstep(lightRadiusPx, 0.0, fragDist);
}
vec2 effectCoord = fragCoord;
vec2 cellIndex = floor(effectCoord / halftoneSize);
vec2 sampleUv = clamp(
(cellIndex + 0.5) * halftoneSize / logicalResolution,
vec2(0.0),
vec2(1.0)
);
vec2 cellUv = fract(effectCoord / halftoneSize);
vec4 sceneSample = texture2D(tScene, sampleUv);
float mask = smoothstep(0.02, 0.08, sceneSample.a);
float lightLift =
hoverLightStrength * hoverLightMask * mix(0.78, 1.18, motionBias) * 0.22;
float bandRadius = clamp(
(
(
sceneSample.r +
sceneSample.g +
sceneSample.b +
s_3 * length(vec2(0.5))
) *
(1.0 / 3.0)
) + lightLift,
0.0,
1.0
) * 1.86 * 0.5;
float alpha = 0.0;
if (bandRadius > 0.0001) {
float signedDistance = lineSimpleEt(cellUv, bandRadius, s_4);
float edge = 0.02;
alpha = (1.0 - smoothstep(0.0, edge, signedDistance)) * mask;
}
float hoverStrength = clamp(hoverLightMask * hoverLightStrength, 0.0, 1.0);
vec3 activeDashColor = mix(dashColor, hoverDashColor, hoverStrength);
vec3 color = activeDashColor * alpha;
gl_FragColor = vec4(color, alpha);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}
`;
const imagePassthroughFragmentShader = `
precision highp float;
uniform sampler2D tImage;
uniform vec2 imageSize;
uniform vec2 viewportSize;
uniform float zoom;
uniform float contrast;
varying vec2 vUv;
void main() {
float imageAspect = imageSize.x / imageSize.y;
float viewAspect = viewportSize.x / viewportSize.y;
vec2 uv = vUv;
if (imageAspect > viewAspect) {
float scale = viewAspect / imageAspect;
uv.x = (uv.x - 0.5) * scale + 0.5;
} else {
float scale = imageAspect / viewAspect;
uv.y = (uv.y - 0.5) * scale + 0.5;
}
uv = (uv - 0.5) / zoom + 0.5;
float inBounds = step(0.0, uv.x) * step(uv.x, 1.0)
* step(0.0, uv.y) * step(uv.y, 1.0);
vec4 color = texture2D(tImage, clamp(uv, 0.0, 1.0));
vec3 contrastColor = clamp((color.rgb - 0.5) * contrast + 0.5, 0.0, 1.0);
gl_FragColor = vec4(contrastColor, inBounds);
}
`;
type Rect = {
x: number;
y: number;
width: number;
height: number;
};
type InteractionState = {
hoverStrength: number;
mouseX: number;
mouseY: number;
pointerInside: boolean;
pointerVelocityX: number;
pointerVelocityY: number;
smoothedMouseX: number;
smoothedMouseY: number;
};
function clampRectToViewport(
rect: Rect,
viewportWidth: number,
viewportHeight: number,
) {
const minX = Math.max(rect.x, 0);
const minY = Math.max(rect.y, 0);
const maxX = Math.min(rect.x + rect.width, viewportWidth);
const maxY = Math.min(rect.y + rect.height, viewportHeight);
if (maxX <= minX || maxY <= minY) {
return null;
}
return {
x: minX,
y: minY,
width: maxX - minX,
height: maxY - minY,
};
}
function getRectArea(rect: Rect | null) {
if (!rect) {
return 0;
}
return Math.max(rect.width, 0) * Math.max(rect.height, 0);
}
function getImagePreviewZoom(previewDistance: number) {
return REFERENCE_PREVIEW_DISTANCE / Math.max(previewDistance, 0.001);
}
function getContainedImageRect({
imageHeight,
imageWidth,
viewportHeight,
viewportWidth,
zoom,
}: {
imageHeight: number;
imageWidth: number;
viewportHeight: number;
viewportWidth: number;
zoom: number;
}) {
if (
imageWidth <= 0 ||
imageHeight <= 0 ||
viewportWidth <= 0 ||
viewportHeight <= 0
) {
return null;
}
const imageAspect = imageWidth / imageHeight;
const viewAspect = viewportWidth / viewportHeight;
let fittedWidth = viewportWidth;
let fittedHeight = viewportHeight;
if (imageAspect > viewAspect) {
fittedHeight = viewportWidth / imageAspect;
} else {
fittedWidth = viewportHeight * imageAspect;
}
const scaledWidth = fittedWidth * zoom;
const scaledHeight = fittedHeight * zoom;
return clampRectToViewport(
{
x: (viewportWidth - scaledWidth) * 0.5,
y: (viewportHeight - scaledHeight) * 0.5,
width: scaledWidth,
height: scaledHeight,
},
viewportWidth,
viewportHeight,
);
}
function getFootprintScaleFromRects(
currentRect: Rect | null,
referenceRect: Rect | null,
) {
const currentArea = getRectArea(currentRect);
const referenceArea = getRectArea(referenceRect);
if (currentArea <= 0 || referenceArea <= 0) {
return 1;
}
return Math.max(
Math.sqrt(currentArea / referenceArea),
MIN_FOOTPRINT_SCALE,
);
}
function getImageFootprintScale({
imageHeight,
imageWidth,
previewDistance,
viewportHeight,
viewportWidth,
}: {
imageHeight: number;
imageWidth: number;
previewDistance: number;
viewportHeight: number;
viewportWidth: number;
}) {
const currentRect = getContainedImageRect({
imageHeight,
imageWidth,
viewportHeight,
viewportWidth,
zoom: getImagePreviewZoom(previewDistance),
});
const referenceRect = getContainedImageRect({
imageHeight,
imageWidth,
viewportHeight,
viewportWidth,
zoom: 1,
});
return getFootprintScaleFromRects(currentRect, referenceRect);
}
function createRenderTarget(width: number, height: number) {
return new THREE.WebGLRenderTarget(width, height, {
minFilter: THREE.LinearFilter,
magFilter: THREE.LinearFilter,
format: THREE.RGBAFormat,
});
}
function createInteractionState(): InteractionState {
return {
hoverStrength: 0,
mouseX: 0.5,
mouseY: 0.5,
pointerInside: false,
pointerVelocityX: 0,
pointerVelocityY: 0,
smoothedMouseX: 0.5,
smoothedMouseY: 0.5,
};
}
function loadImage(imageUrl: string) {
return new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error(`Failed to load image: ${imageUrl}`));
image.src = imageUrl;
});
}
async function mountLiveDataGradient({
container,
config,
imageUrl,
isExternallyActive,
pointerTarget,
}: {
container: HTMLDivElement;
config: ResolvedHalftoneBackdropConfig;
imageUrl: string;
isExternallyActive: () => boolean;
pointerTarget?: HTMLElement | null;
}) {
const HOVER_STRENGTH_STOP_EPSILON = 0.001;
const POINTER_POSITION_STOP_EPSILON = 0.001;
const POINTER_VELOCITY_STOP_EPSILON = 0.0001;
const image = await loadImage(imageUrl);
const getWidth = () => Math.max(container.clientWidth, 1);
const getHeight = () => Math.max(container.clientHeight, 1);
const getVirtualHeight = () => Math.max(VIRTUAL_RENDER_HEIGHT, getHeight());
const getVirtualWidth = () =>
Math.max(
Math.round(getVirtualHeight() * (getWidth() / Math.max(getHeight(), 1))),
1,
);
const renderer = new THREE.WebGLRenderer({ antialias: false, alpha: true });
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.setPixelRatio(1);
renderer.setClearColor(0x000000, 0);
renderer.setSize(getVirtualWidth(), getVirtualHeight(), false);
const canvas = renderer.domElement;
canvas.style.cursor = 'default';
canvas.style.display = 'block';
canvas.style.height = '100%';
canvas.style.touchAction = 'none';
canvas.style.width = '100%';
container.appendChild(canvas);
const trackingElement = pointerTarget ?? canvas;
const imageTexture = new THREE.Texture(image);
imageTexture.colorSpace = THREE.SRGBColorSpace;
imageTexture.needsUpdate = true;
const sceneTarget = createRenderTarget(getVirtualWidth(), getVirtualHeight());
const blurTargetA = createRenderTarget(getVirtualWidth(), getVirtualHeight());
const blurTargetB = createRenderTarget(getVirtualWidth(), getVirtualHeight());
const fullScreenGeometry = new THREE.PlaneGeometry(2, 2);
const orthographicCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
const imageMaterial = new THREE.ShaderMaterial({
uniforms: {
contrast: { value: config.imageContrast },
imageSize: { value: new THREE.Vector2(image.width, image.height) },
tImage: { value: imageTexture },
viewportSize: {
value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
},
zoom: { value: getImagePreviewZoom(config.previewDistance) },
},
fragmentShader: imagePassthroughFragmentShader,
vertexShader: passThroughVertexShader,
});
const blurHorizontalMaterial = new THREE.ShaderMaterial({
uniforms: {
dir: { value: new THREE.Vector2(1, 0) },
res: { value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()) },
tInput: { value: null },
},
fragmentShader: blurFragmentShader,
vertexShader: passThroughVertexShader,
});
const blurVerticalMaterial = new THREE.ShaderMaterial({
uniforms: {
dir: { value: new THREE.Vector2(0, 1) },
res: { value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()) },
tInput: { value: null },
},
fragmentShader: blurFragmentShader,
vertexShader: passThroughVertexShader,
});
const halftoneMaterial = new THREE.ShaderMaterial({
fragmentShader: halftoneFragmentShader,
transparent: true,
uniforms: {
dashColor: { value: new THREE.Color(config.dashColor) },
effectResolution: {
value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
},
footprintScale: { value: 1 },
hoverDashColor: { value: new THREE.Color(config.hoverDashColor) },
hoverLightRadius: { value: config.hoverLightRadius },
hoverLightStrength: { value: 0 },
interactionUv: { value: new THREE.Vector2(0.5, 0.5) },
interactionVelocity: { value: new THREE.Vector2(0, 0) },
logicalResolution: {
value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
},
s_3: { value: config.halftonePower },
s_4: { value: config.halftoneWidth },
tScene: { value: sceneTarget.texture },
tile: { value: config.halftoneScale },
},
vertexShader: passThroughVertexShader,
});
const fullScreenMesh = new THREE.Mesh(fullScreenGeometry, imageMaterial);
const blurHorizontalMesh = new THREE.Mesh(
fullScreenGeometry,
blurHorizontalMaterial,
);
const blurVerticalMesh = new THREE.Mesh(
fullScreenGeometry,
blurVerticalMaterial,
);
const postProcessMesh = new THREE.Mesh(fullScreenGeometry, halftoneMaterial);
const imageScene = new THREE.Scene();
imageScene.add(fullScreenMesh);
const blurHorizontalScene = new THREE.Scene();
blurHorizontalScene.add(blurHorizontalMesh);
const blurVerticalScene = new THREE.Scene();
blurVerticalScene.add(blurVerticalMesh);
const postScene = new THREE.Scene();
postScene.add(postProcessMesh);
const updateViewportUniforms = (
logicalWidth: number,
logicalHeight: number,
effectWidth: number,
effectHeight: number,
) => {
blurHorizontalMaterial.uniforms.res.value.set(effectWidth, effectHeight);
blurVerticalMaterial.uniforms.res.value.set(effectWidth, effectHeight);
halftoneMaterial.uniforms.effectResolution.value.set(
effectWidth,
effectHeight,
);
halftoneMaterial.uniforms.logicalResolution.value.set(
logicalWidth,
logicalHeight,
);
imageMaterial.uniforms.viewportSize.value.set(logicalWidth, logicalHeight);
};
const getHalftoneScale = () =>
getImageFootprintScale({
imageHeight: image.height,
imageWidth: image.width,
previewDistance: config.previewDistance,
viewportHeight: getVirtualHeight(),
viewportWidth: getVirtualWidth(),
});
const interaction = createInteractionState();
let animationFrameId: number | null = null;
let lastFrameTime: number | null = null;
const renderBackdrop = (deltaSeconds: number) => {
const externalActive = isExternallyActive();
const isHoverActive = externalActive || interaction.pointerInside;
const targetHoverStrength = isHoverActive ? 1 : 0;
const hoverEasing =
1 -
Math.exp(
-deltaSeconds *
(isHoverActive ? IMAGE_HOVER_FADE_IN : IMAGE_HOVER_FADE_OUT),
);
interaction.hoverStrength +=
(targetHoverStrength - interaction.hoverStrength) * hoverEasing;
if (
Math.abs(targetHoverStrength - interaction.hoverStrength) <=
HOVER_STRENGTH_STOP_EPSILON
) {
interaction.hoverStrength = targetHoverStrength;
}
const targetMouseX =
externalActive && !interaction.pointerInside
? config.activeHoverX
: interaction.mouseX;
const targetMouseY =
externalActive && !interaction.pointerInside
? config.activeHoverY
: interaction.mouseY;
interaction.smoothedMouseX +=
(targetMouseX - interaction.smoothedMouseX) * IMAGE_POINTER_FOLLOW;
interaction.smoothedMouseY +=
(targetMouseY - interaction.smoothedMouseY) * IMAGE_POINTER_FOLLOW;
if (
Math.abs(targetMouseX - interaction.smoothedMouseX) <=
POINTER_POSITION_STOP_EPSILON
) {
interaction.smoothedMouseX = targetMouseX;
}
if (
Math.abs(targetMouseY - interaction.smoothedMouseY) <=
POINTER_POSITION_STOP_EPSILON
) {
interaction.smoothedMouseY = targetMouseY;
}
interaction.pointerVelocityX *= IMAGE_POINTER_VELOCITY_DAMPING;
interaction.pointerVelocityY *= IMAGE_POINTER_VELOCITY_DAMPING;
if (
Math.abs(interaction.pointerVelocityX) <= POINTER_VELOCITY_STOP_EPSILON
) {
interaction.pointerVelocityX = 0;
}
if (
Math.abs(interaction.pointerVelocityY) <= POINTER_VELOCITY_STOP_EPSILON
) {
interaction.pointerVelocityY = 0;
}
halftoneMaterial.uniforms.interactionUv.value.set(
interaction.smoothedMouseX,
1 - interaction.smoothedMouseY,
);
halftoneMaterial.uniforms.interactionVelocity.value.set(
interaction.pointerVelocityX * getVirtualWidth(),
-interaction.pointerVelocityY * getVirtualHeight(),
);
halftoneMaterial.uniforms.hoverLightStrength.value =
config.hoverLightIntensity * interaction.hoverStrength;
halftoneMaterial.uniforms.hoverLightRadius.value =
config.hoverHalftoneRadius + config.hoverLightRadius * 0.5;
halftoneMaterial.uniforms.footprintScale.value = getHalftoneScale();
imageMaterial.uniforms.zoom.value = getImagePreviewZoom(
config.previewDistance,
);
renderer.setRenderTarget(sceneTarget);
renderer.render(imageScene, orthographicCamera);
blurHorizontalMaterial.uniforms.tInput.value = sceneTarget.texture;
renderer.setRenderTarget(blurTargetA);
renderer.render(blurHorizontalScene, orthographicCamera);
blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture;
renderer.setRenderTarget(blurTargetB);
renderer.render(blurVerticalScene, orthographicCamera);
blurHorizontalMaterial.uniforms.tInput.value = blurTargetB.texture;
renderer.setRenderTarget(blurTargetA);
renderer.render(blurHorizontalScene, orthographicCamera);
blurVerticalMaterial.uniforms.tInput.value = blurTargetA.texture;
renderer.setRenderTarget(blurTargetB);
renderer.render(blurVerticalScene, orthographicCamera);
renderer.setRenderTarget(null);
renderer.clear();
renderer.render(postScene, orthographicCamera);
return (
isHoverActive ||
interaction.hoverStrength !== targetHoverStrength ||
interaction.smoothedMouseX !== targetMouseX ||
interaction.smoothedMouseY !== targetMouseY ||
interaction.pointerVelocityX !== 0 ||
interaction.pointerVelocityY !== 0
);
};
const runFrame = (timestamp: number) => {
animationFrameId = null;
const deltaSeconds =
lastFrameTime === null
? 1 / 60
: Math.min((timestamp - lastFrameTime) / 1000, 0.1);
lastFrameTime = timestamp;
if (renderBackdrop(deltaSeconds)) {
animationFrameId = window.requestAnimationFrame(runFrame);
return;
}
lastFrameTime = null;
};
const ensureAnimationLoop = () => {
if (animationFrameId !== null) {
return;
}
animationFrameId = window.requestAnimationFrame(runFrame);
};
const syncSize = () => {
const virtualWidth = getVirtualWidth();
const virtualHeight = getVirtualHeight();
renderer.setSize(virtualWidth, virtualHeight, false);
sceneTarget.setSize(virtualWidth, virtualHeight);
blurTargetA.setSize(virtualWidth, virtualHeight);
blurTargetB.setSize(virtualWidth, virtualHeight);
updateViewportUniforms(
virtualWidth,
virtualHeight,
virtualWidth,
virtualHeight,
);
if (animationFrameId === null) {
renderBackdrop(0);
}
};
const resizeObserver = new ResizeObserver(syncSize);
resizeObserver.observe(container);
syncSize();
const updatePointerPosition = (
event: PointerEvent,
options?: { resetVelocity?: boolean },
) => {
const rect = trackingElement.getBoundingClientRect();
const width = Math.max(rect.width, 1);
const height = Math.max(rect.height, 1);
const nextMouseX = THREE.MathUtils.clamp(
(event.clientX - rect.left) / width,
0,
1,
);
const nextMouseY = THREE.MathUtils.clamp(
(event.clientY - rect.top) / height,
0,
1,
);
const deltaX = nextMouseX - interaction.mouseX;
const deltaY = nextMouseY - interaction.mouseY;
interaction.mouseX = nextMouseX;
interaction.mouseY = nextMouseY;
interaction.pointerInside =
event.clientX >= rect.left &&
event.clientX <= rect.right &&
event.clientY >= rect.top &&
event.clientY <= rect.bottom;
if (options?.resetVelocity) {
interaction.pointerVelocityX = 0;
interaction.pointerVelocityY = 0;
interaction.smoothedMouseX = nextMouseX;
interaction.smoothedMouseY = nextMouseY;
return;
}
interaction.pointerVelocityX = deltaX;
interaction.pointerVelocityY = deltaY;
};
const handlePointerMove = (event: PointerEvent) => {
const shouldReset = !interaction.pointerInside;
updatePointerPosition(
event,
shouldReset ? { resetVelocity: true } : undefined,
);
ensureAnimationLoop();
};
const handlePointerLeave = () => {
interaction.pointerInside = false;
interaction.pointerVelocityX = 0;
interaction.pointerVelocityY = 0;
ensureAnimationLoop();
};
trackingElement.addEventListener('pointermove', handlePointerMove);
trackingElement.addEventListener('pointerleave', handlePointerLeave);
if (isExternallyActive()) {
ensureAnimationLoop();
}
return {
dispose: () => {
if (animationFrameId !== null) {
window.cancelAnimationFrame(animationFrameId);
}
resizeObserver.disconnect();
trackingElement.removeEventListener('pointermove', handlePointerMove);
trackingElement.removeEventListener('pointerleave', handlePointerLeave);
blurHorizontalMaterial.dispose();
blurVerticalMaterial.dispose();
halftoneMaterial.dispose();
imageMaterial.dispose();
imageTexture.dispose();
fullScreenGeometry.dispose();
sceneTarget.dispose();
blurTargetA.dispose();
blurTargetB.dispose();
renderer.dispose();
if (canvas.parentNode === container) {
container.removeChild(canvas);
}
},
wake: ensureAnimationLoop,
};
}
const VisualMount = styled.div`
background: transparent;
display: block;
height: 100%;
min-width: 0;
width: 100%;
`;
type LiveDataGradientBackdropProps = {
active?: boolean;
config?: HalftoneBackdropConfig;
imageUrl?: string;
pointerTargetRef?: RefObject<HTMLElement | null>;
};
export function LiveDataGradientBackdrop({
active = false,
config,
imageUrl = DEFAULT_IMAGE_URL,
pointerTargetRef,
}: LiveDataGradientBackdropProps) {
const mountRef = useRef<HTMLDivElement>(null);
const activeRef = useRef(active);
const wakeRef = useRef<VoidFunction | null>(null);
const resolvedConfig = {
...DEFAULT_HALFTONE_BACKDROP_CONFIG,
...config,
};
activeRef.current = active;
useEffect(() => {
const container = mountRef.current;
if (!container) {
return;
}
let dispose: VoidFunction | undefined;
let cancelled = false;
void mountLiveDataGradient({
config: resolvedConfig,
container,
imageUrl,
isExternallyActive: () => activeRef.current,
pointerTarget: pointerTargetRef?.current,
})
.then((mountedGradient) => {
if (cancelled) {
mountedGradient.dispose();
return;
}
wakeRef.current = mountedGradient.wake;
dispose = mountedGradient.dispose;
})
.catch((error) => {
console.error(error);
});
return () => {
cancelled = true;
wakeRef.current = null;
dispose?.();
};
}, [
imageUrl,
resolvedConfig.dashColor,
resolvedConfig.halftonePower,
resolvedConfig.halftoneScale,
resolvedConfig.halftoneWidth,
resolvedConfig.hoverDashColor,
resolvedConfig.hoverHalftoneRadius,
resolvedConfig.hoverLightIntensity,
resolvedConfig.hoverLightRadius,
resolvedConfig.imageContrast,
resolvedConfig.previewDistance,
]);
useEffect(() => {
wakeRef.current?.();
}, [active]);
return <VisualMount aria-hidden ref={mountRef} />;
}
@@ -15,15 +15,19 @@ import {
useRef,
useState,
type PointerEvent as ReactPointerEvent,
type ReactNode,
} from 'react';
import { Chip, ChipVariant } from '@/sections/Hero/components/HomeVisual/homeVisualChip';
import {
Chip,
ChipVariant,
} from '@/sections/Hero/components/HomeVisual/homeVisualChip';
import { VISUAL_TOKENS } from '@/sections/Hero/components/HomeVisual/homeVisualTokens';
const APP_FONT = VISUAL_TOKENS.font.family;
const TABLE_CELL_HORIZONTAL_PADDING = 8;
const TABLER_STROKE = 1.6;
const DEFAULT_TABLE_WIDTH = 520;
const ROW_ENTER_DURATION_MS = 760;
const ROW_ENTER_STAGGER_MS = 160;
const COLORS = {
accentBorder: VISUAL_TOKENS.border.color.blue,
@@ -47,11 +51,25 @@ const COLORS = {
const TABLE_COLUMNS = [
{ id: 'company', isFirstColumn: true, label: 'Companies', width: 180 },
{ id: 'type', isFirstColumn: false, label: 'Type', width: 160 },
{ id: 'type', isFirstColumn: false, label: 'Type', width: 132 },
{ id: 'url', isFirstColumn: false, label: 'Domain', width: 150 },
] as const;
const TABLE_ROWS = [
type TableRow = {
company: string;
domain: string;
isNew?: boolean;
logoSrc: string;
status: string;
};
const BASE_TABLE_ROWS: ReadonlyArray<TableRow> = [
{
company: 'Anthropic',
domain: 'anthropic.com',
logoSrc: SHARED_COMPANY_LOGO_URLS.anthropic,
status: 'Customer',
},
{
company: 'Slack',
domain: 'slack.com',
@@ -70,7 +88,31 @@ const TABLE_ROWS = [
logoSrc: SHARED_COMPANY_LOGO_URLS.sequoia,
status: 'Customer',
},
] as const;
{
company: 'Cursor',
domain: 'cursor.com',
logoSrc: SHARED_COMPANY_LOGO_URLS.cursor,
status: 'Customer',
},
];
const EXPANDED_TABLE_ROWS: ReadonlyArray<TableRow> = [
...BASE_TABLE_ROWS,
{
company: 'Twenty',
domain: 'twenty.com',
isNew: true,
logoSrc: SHARED_COMPANY_LOGO_URLS.twenty,
status: 'Customer',
},
{
company: 'Linear',
domain: 'linear.app',
isNew: true,
logoSrc: SHARED_COMPANY_LOGO_URLS.linear,
status: 'Customer',
},
];
const TableShell = styled.div`
display: flex;
@@ -81,19 +123,6 @@ const TableShell = styled.div`
width: 100%;
`;
const GripRail = styled.div`
background: ${COLORS.background};
display: grid;
flex: 0 0 12px;
grid-auto-rows: 32px;
width: 12px;
`;
const GripCell = styled.div`
background: ${COLORS.background};
border-bottom: 1px solid ${COLORS.borderLight};
`;
const TableViewport = styled.div<{ $dragging: boolean }>`
cursor: ${({ $dragging }) => ($dragging ? 'grabbing' : 'grab')};
flex: 1 1 auto;
@@ -126,6 +155,38 @@ const DataRow = styled.div`
display: flex;
`;
const RowMotion = styled.div<{ $delayMs?: number; $entering?: boolean }>`
align-items: center;
animation: ${({ $entering }) =>
$entering
? `live-data-row-enter ${ROW_ENTER_DURATION_MS}ms cubic-bezier(0.16, 1, 0.3, 1)`
: 'none'};
animation-delay: ${({ $delayMs = 0 }) => `${$delayMs}ms`};
animation-fill-mode: both;
display: flex;
height: 100%;
min-width: 0;
width: 100%;
will-change: opacity, transform;
@keyframes live-data-row-enter {
0% {
opacity: 0;
transform: translate3d(0, 28px, 0);
}
58% {
opacity: 1;
transform: translate3d(0, 6px, 0);
}
100% {
opacity: 1;
transform: translate3d(0, 0, 0);
}
}
`;
const FooterRow = styled.div`
display: flex;
`;
@@ -312,7 +373,11 @@ const StatusChip = styled.div<{ $edited?: boolean; $hoveredByAlice?: boolean }>`
white-space: nowrap;
`;
function HeaderIcon({ columnId }: { columnId: (typeof TABLE_COLUMNS)[number]['id'] }) {
function HeaderIcon({
columnId,
}: {
columnId: (typeof TABLE_COLUMNS)[number]['id'];
}) {
if (columnId === 'url') {
return (
<IconLink
@@ -367,13 +432,7 @@ function PlusMini({ size = 12 }: { size?: number }) {
);
}
function CompanyCell({
label,
logoSrc,
}: {
label: string;
logoSrc: string;
}) {
function CompanyCell({ label, logoSrc }: { label: string; logoSrc: string }) {
return (
<CellHoverAnchor>
<CheckboxContainer>
@@ -384,7 +443,12 @@ function CompanyCell({
label={label}
leftComponent={
<LogoBase>
<CompanyLogoImage alt="" decoding="async" loading="lazy" src={logoSrc} />
<CompanyLogoImage
alt=""
decoding="async"
loading="lazy"
src={logoSrc}
/>
</LogoBase>
}
variant={ChipVariant.Highlighted}
@@ -405,12 +469,14 @@ type LiveDataHeroTableProps = {
editedStatusLabel: string;
isFirstTagEdited: boolean;
isFirstTagHoveredByAlice: boolean;
showExtendedRows: boolean;
};
export function LiveDataHeroTable({
editedStatusLabel,
isFirstTagEdited,
isFirstTagHoveredByAlice,
showExtendedRows,
}: LiveDataHeroTableProps) {
const viewportRef = useRef<HTMLDivElement>(null);
const dragRef = useRef({
@@ -421,6 +487,7 @@ export function LiveDataHeroTable({
});
const [dragging, setDragging] = useState(false);
const [hoveredRowIndex, setHoveredRowIndex] = useState<number | null>(null);
const visibleRows = showExtendedRows ? EXPANDED_TABLE_ROWS : BASE_TABLE_ROWS;
const columnWidth = TABLE_COLUMNS.reduce(
(sum, column) => sum + column.width,
@@ -514,14 +581,6 @@ export function LiveDataHeroTable({
return (
<TableShell>
<GripRail aria-hidden="true">
<GripCell />
{TABLE_ROWS.map((row) => (
<GripCell key={`grip-${row.company}`} />
))}
<GripCell />
</GripRail>
<TableViewport
ref={viewportRef}
$dragging={dragging}
@@ -571,8 +630,11 @@ export function LiveDataHeroTable({
</EmptyFillCell>
</HeaderRow>
{TABLE_ROWS.map((row, index) => {
{visibleRows.map((row, index) => {
const hovered = hoveredRowIndex === index;
const enterDelayMs = row.isNew
? ROW_ENTER_STAGGER_MS * (index - BASE_TABLE_ROWS.length + 1)
: 0;
return (
<DataRow
@@ -589,22 +651,30 @@ export function LiveDataHeroTable({
$sticky
$width={TABLE_COLUMNS[0].width}
>
<CompanyCell label={row.company} logoSrc={row.logoSrc} />
<RowMotion $delayMs={enterDelayMs} $entering={row.isNew}>
<CompanyCell label={row.company} logoSrc={row.logoSrc} />
</RowMotion>
</TableCell>
<TableCell $hovered={hovered} $width={TABLE_COLUMNS[1].width}>
<StatusChip
$edited={index === 0 && isFirstTagEdited}
$hoveredByAlice={index === 0 && isFirstTagHoveredByAlice}
>
{index === 0 && isFirstTagEdited
? editedStatusLabel
: row.status}
</StatusChip>
<RowMotion $delayMs={enterDelayMs} $entering={row.isNew}>
<StatusChip
$edited={index === 0 && isFirstTagEdited}
$hoveredByAlice={index === 0 && isFirstTagHoveredByAlice}
>
{index === 0 && isFirstTagEdited
? editedStatusLabel
: row.status}
</StatusChip>
</RowMotion>
</TableCell>
<TableCell $hovered={hovered} $width={TABLE_COLUMNS[2].width}>
<LinkCell label={row.domain} />
<RowMotion $delayMs={enterDelayMs} $entering={row.isNew}>
<LinkCell label={row.domain} />
</RowMotion>
</TableCell>
<EmptyFillCell $hovered={hovered} $width={fillerWidth} />
<EmptyFillCell $hovered={hovered} $width={fillerWidth}>
<RowMotion $delayMs={enterDelayMs} $entering={row.isNew} />
</EmptyFillCell>
</DataRow>
);
})}
@@ -3,13 +3,15 @@
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import {
IconCheckbox,
IconChevronDown,
IconHeartHandshake,
IconList,
IconPlus,
IconUser,
IconX,
} from '@tabler/icons-react';
import { useEffect, useState } from 'react';
import { type RefObject, useEffect, useRef, useState } from 'react';
import { LiveDataGradientBackdrop } from './LiveDataGradientBackdrop';
import { LiveDataHeroTable } from './LiveDataHeroTable';
const APP_FONT = `'Inter', ${theme.font.family.sans}`;
@@ -18,18 +20,28 @@ const SCENE_WIDTH = 411;
const SCENE_SCALE = 1;
const SCENE_SCALE_MD = 0.86;
const SCENE_SCALE_SM = 0.74;
const TABLE_PANEL_HOVER_SCALE = 1.012;
const TABLER_STROKE = 1.65;
const FILTER_ICON_STROKE = 1.33;
const FILTER_ROW_GAP = 4;
const BOB_READY_CURSOR_ROTATION = 135;
const MARKER_CURSOR_HEIGHT = 32;
const MARKER_CURSOR_WIDTH = 29;
const BOB_MARKER_LEFT = 196;
const BOB_MARKER_TOP = 372;
const BOB_ARROW_LEFT_OFFSET = 20;
const DEFAULT_ADD_FILTER_LEFTS = {
docked: 152,
parked: 276,
};
const COLORS = {
backdrop: '#1b1b1b',
backdropDot: 'rgba(255, 255, 255, 0.14)',
backdropStripe: 'rgba(255, 255, 255, 0.20)',
black: '#1f1f1f',
bobCursor: '#EDB7FF',
blue: '#1961ed',
blueBorder: '#edf2fe',
blueSurface: '#f5f9fd',
cardSurface: '#f5f5f3',
green: '#18794e',
greenSurface: '#ddf3e4',
muted: '#999999',
@@ -42,10 +54,19 @@ const COLORS = {
yellow: '#fff6a5',
} as const;
type AliceReturnPhase = 'return-to-start';
type BobLiveDataPhase =
| 'bob-ready'
| 'move-to-filter'
| 'remove-filter'
| 'return-bob';
type LiveDataPhase =
| 'idle'
| 'move-to-tag'
| 'rename-tag'
| AliceReturnPhase
| BobLiveDataPhase
| 'settle';
const LIVE_DATA_SEQUENCE: Array<{
@@ -54,25 +75,48 @@ const LIVE_DATA_SEQUENCE: Array<{
}> = [
{ delay: 0, phase: 'move-to-tag' },
{ delay: 760, phase: 'rename-tag' },
{ delay: 1860, phase: 'settle' },
{ delay: 1700, phase: 'return-to-start' },
{ delay: 2380, phase: 'bob-ready' },
{ delay: 2620, phase: 'move-to-filter' },
{ delay: 3260, phase: 'remove-filter' },
{ delay: 3900, phase: 'return-bob' },
{ delay: 4680, phase: 'settle' },
];
const LIVE_DATA_LOOP_DURATION = 3200;
const EDITED_TAG_LABEL = 'Priority';
const TYPING_STEP_MS = 90;
const ALICE_IDLE_MARKER = { left: 238, rotation: 0, top: 90 };
const BOB_READY_MARKER = {
bottom: SCENE_HEIGHT - (BOB_MARKER_TOP + MARKER_CURSOR_HEIGHT / 2),
right:
SCENE_WIDTH -
(BOB_MARKER_LEFT + BOB_ARROW_LEFT_OFFSET + MARKER_CURSOR_WIDTH / 2),
rotation: BOB_READY_CURSOR_ROTATION,
};
const BOB_FILTER_CURSOR_ROTATION = 132;
const BOB_FILTER_MARKER = {
bottom: 301,
right: 50,
rotation: BOB_FILTER_CURSOR_ROTATION,
};
const LIVE_DATA_CURSOR_POSITIONS: Record<
const ALICE_MARKER_POSITIONS: Record<
LiveDataPhase,
{ left: number; rotation: number; top: number }
> = {
idle: { left: 238, rotation: 0, top: 90 },
'move-to-tag': { left: 286, rotation: -38, top: 272 },
'rename-tag': { left: 279, rotation: -18, top: 273 },
settle: { left: 293, rotation: -18, top: 277 },
idle: ALICE_IDLE_MARKER,
'move-to-tag': { left: 286, rotation: -38, top: 246 },
'rename-tag': { left: 279, rotation: -18, top: 247 },
'return-to-start': ALICE_IDLE_MARKER,
'bob-ready': ALICE_IDLE_MARKER,
'move-to-filter': ALICE_IDLE_MARKER,
'remove-filter': ALICE_IDLE_MARKER,
'return-bob': ALICE_IDLE_MARKER,
settle: ALICE_IDLE_MARKER,
};
const VisualRoot = styled.div`
background: ${COLORS.cardSurface};
background: ${COLORS.backdrop};
height: 100%;
overflow: hidden;
position: relative;
@@ -80,13 +124,13 @@ const VisualRoot = styled.div`
`;
const SceneViewport = styled.div`
height: ${SCENE_HEIGHT}px;
bottom: 0;
left: 50%;
position: absolute;
top: 16px;
top: 0;
transform: translateX(-50%) scale(${SCENE_SCALE});
transform-origin: top center;
width: ${SCENE_WIDTH}px;
width: 101%;
@media (max-width: ${theme.breakpoints.md - 1}px) {
transform: translateX(-50%) scale(${SCENE_SCALE_MD});
@@ -98,60 +142,53 @@ const SceneViewport = styled.div`
`;
const SceneFrame = styled.div`
background: ${COLORS.backdrop};
border-radius: 2px;
height: 100%;
overflow: hidden;
position: relative;
width: 100%;
&::before {
background: radial-gradient(
circle at 1px 1px,
${COLORS.backdropDot} 1px,
transparent 1.2px
),
repeating-linear-gradient(
90deg,
transparent 0 18px,
${COLORS.backdropStripe} 18px 25px,
transparent 25px 52px
);
background-size:
12px 12px,
100% 32px;
content: '';
inset: 0;
opacity: 0.8;
position: absolute;
}
`;
const AccentRail = styled.div`
background: repeating-linear-gradient(
180deg,
${COLORS.backdropStripe} 0 4px,
transparent 4px 11px
);
bottom: 0;
left: 0;
opacity: 0.9;
const SceneBackdrop = styled.div<{
$backgroundImageRotationDeg?: number;
}>`
background-color: ${COLORS.backdrop};
inset: 0;
pointer-events: none;
position: absolute;
top: 67%;
width: 58px;
`;
const TablePanel = styled.div`
const SceneContent = styled.div`
inset: 16px 0 0;
position: absolute;
width: 100%;
`;
const TablePanel = styled.div<{ $active?: boolean }>`
backface-visibility: hidden;
background: ${COLORS.white};
border-top-left-radius: ${theme.radius(1)};
box-shadow: 0 0 0 1px rgba(241, 241, 241, 0.9);
border-top-left-radius: 8px;
box-shadow: ${({ $active }) =>
$active
? '0 0 0 1px rgba(241, 241, 241, 0.9), 0 14px 28px rgba(15, 23, 42, 0.08)'
: '0 0 0 1px rgba(241, 241, 241, 0.9)'};
contain: paint;
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
height: 341px;
left: 58px;
height: 376px;
bottom: -4px;
overflow: hidden;
position: absolute;
top: 151px;
right: 0px;
transform: ${({ $active }) =>
`translate3d(0, 0, 0) scale(${
$active ? TABLE_PANEL_HOVER_SCALE : 1
})`};
transform-origin: bottom right;
transition:
box-shadow 260ms cubic-bezier(0.22, 1, 0.36, 1),
transform 260ms cubic-bezier(0.22, 1, 0.36, 1);
will-change: transform, box-shadow;
width: 355px;
`;
@@ -232,12 +269,34 @@ const FilterRow = styled.div`
align-items: center;
border-bottom: 1px solid #f1f1f1;
display: flex;
gap: 4px;
gap: ${FILTER_ROW_GAP}px;
height: 48px;
padding: 0 12px;
position: relative;
`;
const FilterChip = styled.div<{ $pressed?: boolean }>`
const FilterChipMotion = styled.div<{ $removing?: boolean; $visible: boolean }>`
display: inline-flex;
max-width: ${({ $visible }) => ($visible ? '172px' : '0')};
opacity: ${({ $removing, $visible }) => ($visible || $removing ? 1 : 0)};
overflow: ${({ $removing }) => ($removing ? 'visible' : 'hidden')};
pointer-events: ${({ $removing, $visible }) =>
$visible && !$removing ? 'auto' : 'none'};
transform: ${({ $removing, $visible }) =>
$visible || $removing
? 'translateX(0) scale(1)'
: 'translateX(-8px) scale(0.92)'};
transform-origin: right center;
transition:
max-width 280ms cubic-bezier(0.22, 1, 0.36, 1)
${({ $removing }) => ($removing ? '180ms' : '0ms')},
opacity 180ms ease ${({ $removing }) => ($removing ? '180ms' : '0ms')},
transform 280ms cubic-bezier(0.22, 1, 0.36, 1)
${({ $removing }) => ($removing ? '180ms' : '0ms')};
white-space: nowrap;
`;
const FilterChip = styled.div<{ $pressed?: boolean; $removing?: boolean }>`
align-items: center;
background: ${COLORS.blueSurface};
border: 1px solid ${COLORS.blueBorder};
@@ -252,11 +311,36 @@ const FilterChip = styled.div<{ $pressed?: boolean }>`
height: 24px;
line-height: 1.4;
padding: 2px 2px 2px 4px;
opacity: 1;
transform: ${({ $pressed }) => ($pressed ? 'scale(0.985)' : 'scale(1)')};
transition:
box-shadow 180ms ease,
transform 180ms ease;
white-space: nowrap;
animation: ${({ $removing }) =>
$removing
? 'employees-filter-pop-away 320ms cubic-bezier(0.18, 1, 0.32, 1) forwards'
: 'none'};
transform-origin: right center;
will-change: opacity, transform;
@keyframes employees-filter-pop-away {
0% {
opacity: 1;
transform: scale(0.985) translate3d(0, 0, 0);
}
36% {
opacity: 1;
transform: scale(1.08) translate3d(2px, -1px, 0);
}
100% {
opacity: 0;
transform: scale(0.64) translate3d(18px, -6px, 0);
}
}
`;
const FilterChipLabel = styled.span`
@@ -286,9 +370,10 @@ const FilterValue = styled.span`
font-weight: ${theme.font.weight.regular};
`;
const FilterCloseButton = styled.button`
const FilterCloseButton = styled.button<{ $pressed?: boolean }>`
align-items: center;
background: transparent;
background: ${({ $pressed }) =>
$pressed ? 'rgba(25, 97, 237, 0.08)' : 'transparent'};
border: 0;
border-radius: 2px;
color: ${COLORS.blue};
@@ -298,7 +383,18 @@ const FilterCloseButton = styled.button`
height: 20px;
justify-content: center;
padding: 0;
transition: background-color 140ms ease;
width: 20px;
&:hover {
background: ${({ $pressed }) =>
$pressed ? 'rgba(25, 97, 237, 0.08)' : 'rgba(25, 97, 237, 0.06)'};
}
&:focus-visible {
background: rgba(25, 97, 237, 0.06);
outline: none;
}
`;
const AddFilter = styled.button`
@@ -325,6 +421,19 @@ const AddFilter = styled.button`
}
`;
const FloatingAddFilter = styled(AddFilter)<{
$animated: boolean;
$left: number;
}>`
left: ${({ $left }) => `${$left}px`};
margin-left: 0;
position: absolute;
top: 50%;
transform: translateY(-50%);
transition: ${({ $animated }) =>
$animated ? 'left 340ms cubic-bezier(0.22, 1, 0.36, 1)' : 'none'};
`;
const TableBodyArea = styled.div`
min-height: 0;
min-width: 0;
@@ -354,25 +463,50 @@ const CursorGlyph = styled.svg<{
width: ${({ $width }) => `${$width}px`};
`;
const AnimatedCursorLayer = styled.div<{
const AnimatedMarkerGroup = styled.div<{
$left: number;
$pressed?: boolean;
$top: number;
}>`
animation: ${({ $pressed }) =>
$pressed ? 'cursor-pop 240ms cubic-bezier(0.22, 1, 0.36, 1)' : 'none'};
left: ${({ $left }) => `${$left}px`};
opacity: 1;
pointer-events: none;
position: absolute;
top: ${({ $top }) => `${$top}px`};
transform: translate(-50%, -50%) scale(1);
transition:
left 620ms cubic-bezier(0.22, 1, 0.36, 1),
opacity 180ms ease,
top 620ms cubic-bezier(0.22, 1, 0.36, 1),
transform 140ms ease;
top 620ms cubic-bezier(0.22, 1, 0.36, 1);
z-index: 9;
`;
const AnchoredMarkerGroup = styled.div<{
$bottom: number;
$right: number;
}>`
bottom: ${({ $bottom }) => `${$bottom}px`};
pointer-events: none;
position: absolute;
right: ${({ $right }) => `${$right}px`};
transition:
bottom 620ms cubic-bezier(0.22, 1, 0.36, 1),
right 620ms cubic-bezier(0.22, 1, 0.36, 1);
z-index: 9;
`;
const MarkerCursorSlot = styled.div<{
$pressed?: boolean;
$visible?: boolean;
}>`
animation: ${({ $pressed, $visible }) =>
$pressed && $visible
? 'cursor-pop 240ms cubic-bezier(0.22, 1, 0.36, 1)'
: 'none'};
left: 0;
opacity: ${({ $visible = true }) => ($visible ? 1 : 0)};
position: absolute;
top: 0;
transform: translate(-50%, -50%) scale(1);
transition:
opacity 180ms ease,
transform 140ms ease;
@keyframes cursor-pop {
0% {
@@ -391,20 +525,25 @@ const AnimatedCursorLayer = styled.div<{
transform: translate(-50%, -50%) scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
animation: none;
}
`;
const AnimatedNameTagLayer = styled.div<{
$left: number;
$top: number;
}>`
left: ${({ $left }) => `${$left}px`};
pointer-events: none;
const MarkerLabelTop = styled.div`
left: 0;
position: absolute;
top: ${({ $top }) => `${$top}px`};
transform: translate(-50%, calc(-100% - 18px));
transition:
left 620ms cubic-bezier(0.22, 1, 0.36, 1),
top 620ms cubic-bezier(0.22, 1, 0.36, 1);
top: 0;
transform: translate(-50%, calc(-100% - 24px));
z-index: 10;
`;
const MarkerLabelBottom = styled.div`
left: 0;
position: absolute;
top: 0;
transform: translate(-50%, 26px);
z-index: 10;
`;
@@ -423,7 +562,7 @@ const TomMarker = styled.div`
left: 31px;
pointer-events: auto;
position: absolute;
top: 306px;
top: 271px;
height: 112px;
width: 92px;
z-index: 8;
@@ -474,11 +613,13 @@ const TomCursorGlyph = styled.svg`
width: 41px;
`;
const BobMarker = styled.div`
pointer-events: auto;
left: 214px;
const BobMarker = styled.div<{ $hidden?: boolean }>`
left: ${BOB_MARKER_LEFT}px;
opacity: ${({ $hidden }) => ($hidden ? 0 : 1)};
pointer-events: ${({ $hidden }) => ($hidden ? 'none' : 'auto')};
position: absolute;
top: 386px;
top: ${BOB_MARKER_TOP}px;
transition: opacity 180ms ease;
height: 112px;
width: 96px;
z-index: 8;
@@ -492,19 +633,21 @@ const BobMarkerMotion = styled.div<{ $hovered: boolean }>`
`;
const BobArrow = styled.div`
margin-left: 20px;
height: ${MARKER_CURSOR_HEIGHT}px;
margin-left: ${BOB_ARROW_LEFT_OFFSET}px;
position: relative;
width: ${MARKER_CURSOR_WIDTH}px;
z-index: 2;
`;
const BobLabel = styled.div`
const BobLabel = styled.div<{ $withTopMargin?: boolean }>`
align-items: center;
background: ${COLORS.purple};
background: ${COLORS.bobCursor};
border-radius: 4px;
display: inline-flex;
height: 38px;
justify-content: center;
margin-top: 10px;
margin-top: ${({ $withTopMargin }) => ($withTopMargin ? '10px' : '0')};
min-width: 60px;
padding: 0 12px;
position: relative;
@@ -556,13 +699,27 @@ function TomCursor() {
type LiveDataVisualProps = {
active?: boolean;
backgroundImageRotationDeg?: number;
backgroundImageSrc?: string;
pointerTargetRef?: RefObject<HTMLElement | null>;
};
export function LiveDataVisual({ active = false }: LiveDataVisualProps) {
export function LiveDataVisual({
active = false,
backgroundImageSrc,
pointerTargetRef,
}: LiveDataVisualProps) {
const rootRef = useRef<HTMLDivElement>(null);
const typeFilterRef = useRef<HTMLDivElement>(null);
const employeesFilterRef = useRef<HTMLDivElement>(null);
const [isBobHovered, setIsBobHovered] = useState(false);
const [isTomHovered, setIsTomHovered] = useState(false);
const [phase, setPhase] = useState<LiveDataPhase>('idle');
const [typedTagLabel, setTypedTagLabel] = useState('');
const [addFilterLefts, setAddFilterLefts] = useState<{
docked: number;
parked: number;
} | null>(null);
useEffect(() => {
if (!active) {
@@ -570,36 +727,18 @@ export function LiveDataVisual({ active = false }: LiveDataVisualProps) {
return;
}
let cancelled = false;
const timeoutIds: number[] = [];
setPhase('move-to-tag');
const runSequence = () => {
setPhase('move-to-tag');
for (const step of LIVE_DATA_SEQUENCE.slice(1)) {
const timeoutId = window.setTimeout(() => {
setPhase(step.phase);
}, step.delay);
for (const step of LIVE_DATA_SEQUENCE.slice(1)) {
const timeoutId = window.setTimeout(() => {
if (!cancelled) {
setPhase(step.phase);
}
}, step.delay);
timeoutIds.push(timeoutId);
}
const loopTimeoutId = window.setTimeout(() => {
if (!cancelled) {
runSequence();
}
}, LIVE_DATA_LOOP_DURATION);
timeoutIds.push(loopTimeoutId);
};
runSequence();
timeoutIds.push(timeoutId);
}
return () => {
cancelled = true;
for (const timeoutId of timeoutIds) {
window.clearTimeout(timeoutId);
}
@@ -627,7 +766,14 @@ export function LiveDataVisual({ active = false }: LiveDataVisualProps) {
};
}
if (phase === 'settle') {
if (
phase === 'return-to-start' ||
phase === 'bob-ready' ||
phase === 'move-to-filter' ||
phase === 'remove-filter' ||
phase === 'return-bob' ||
phase === 'settle'
) {
setTypedTagLabel(EDITED_TAG_LABEL);
return;
}
@@ -635,160 +781,279 @@ export function LiveDataVisual({ active = false }: LiveDataVisualProps) {
setTypedTagLabel('');
}, [phase]);
const animatedCursor = LIVE_DATA_CURSOR_POSITIONS[phase];
const isFirstTagRenamed = phase === 'rename-tag' || phase === 'settle';
const isFirstTagEdited = phase === 'rename-tag' || phase === 'settle';
const isFirstTagHoveredByAlice = phase !== 'idle';
useEffect(() => {
const measureAddFilterLefts = () => {
const typeFilter = typeFilterRef.current;
const employeesFilter = employeesFilterRef.current;
if (
!typeFilter ||
!employeesFilter ||
employeesFilter.offsetWidth === 0
) {
return;
}
const nextLefts = {
docked: typeFilter.offsetLeft + typeFilter.offsetWidth + FILTER_ROW_GAP,
parked:
employeesFilter.offsetLeft +
employeesFilter.offsetWidth +
FILTER_ROW_GAP,
};
setAddFilterLefts((current) =>
current?.docked === nextLefts.docked &&
current?.parked === nextLefts.parked
? current
: nextLefts,
);
};
const frameId = window.requestAnimationFrame(measureAddFilterLefts);
window.addEventListener('resize', measureAddFilterLefts);
void document.fonts?.ready.then(measureAddFilterLefts);
return () => {
window.cancelAnimationFrame(frameId);
window.removeEventListener('resize', measureAddFilterLefts);
};
}, []);
const aliceMarker = ALICE_MARKER_POSITIONS[phase];
const bobCursor = active
? phase === 'move-to-filter' || phase === 'remove-filter'
? BOB_FILTER_MARKER
: BOB_READY_MARKER
: null;
const isAliceCursorVisible =
phase === 'idle' ||
phase === 'move-to-tag' ||
phase === 'rename-tag' ||
phase === 'return-to-start' ||
phase === 'bob-ready' ||
phase === 'move-to-filter' ||
phase === 'remove-filter' ||
phase === 'return-bob' ||
phase === 'settle';
const isBobCursorVisible = active;
const isEmployeesFilterRemoving = phase === 'remove-filter';
const isEmployeesFilterVisible =
phase !== 'remove-filter' && phase !== 'return-bob' && phase !== 'settle';
const hasEmployeesFilterBeenRemoved =
phase === 'remove-filter' || phase === 'return-bob' || phase === 'settle';
const isFirstTagRenamed =
phase === 'rename-tag' ||
phase === 'return-to-start' ||
phase === 'bob-ready' ||
phase === 'move-to-filter' ||
phase === 'remove-filter' ||
phase === 'return-bob' ||
phase === 'settle';
const isFirstTagEdited = isFirstTagRenamed;
const isFirstTagHoveredByAlice =
phase === 'move-to-tag' || phase === 'rename-tag';
const isAddFilterDocked = phase === 'return-bob' || phase === 'settle';
const addFilterLeft = isAddFilterDocked
? (addFilterLefts?.docked ?? DEFAULT_ADD_FILTER_LEFTS.docked)
: (addFilterLefts?.parked ?? DEFAULT_ADD_FILTER_LEFTS.parked);
const viewCount = hasEmployeesFilterBeenRemoved ? 11 : 9;
return (
<VisualRoot aria-hidden>
<VisualRoot aria-hidden ref={rootRef}>
<SceneViewport>
<SceneFrame>
<AccentRail />
<AnimatedNameTagLayer
$left={animatedCursor.left}
$top={animatedCursor.top}
>
<AliceLabel>
<MarkerText>Alice</MarkerText>
</AliceLabel>
</AnimatedNameTagLayer>
<AnimatedCursorLayer
$left={animatedCursor.left}
$pressed={phase === 'rename-tag'}
$top={animatedCursor.top}
>
<MarkerCursor
color={COLORS.yellow}
rotation={animatedCursor.rotation}
<SceneBackdrop>
<LiveDataGradientBackdrop
active={active}
imageUrl={backgroundImageSrc}
pointerTargetRef={pointerTargetRef ?? rootRef}
/>
</AnimatedCursorLayer>
</SceneBackdrop>
<SceneContent>
<AnimatedMarkerGroup
$left={aliceMarker.left}
$top={aliceMarker.top}
>
<MarkerLabelTop>
<AliceLabel>
<MarkerText>Alice</MarkerText>
</AliceLabel>
</MarkerLabelTop>
<MarkerCursorSlot
$pressed={phase === 'rename-tag'}
$visible={isAliceCursorVisible}
>
<MarkerCursor
color={COLORS.yellow}
rotation={aliceMarker.rotation}
/>
</MarkerCursorSlot>
</AnimatedMarkerGroup>
{bobCursor ? (
<AnchoredMarkerGroup
$bottom={bobCursor.bottom}
$right={bobCursor.right}
>
<MarkerCursorSlot
$pressed={phase === 'remove-filter'}
$visible
>
<MarkerCursor
color={COLORS.bobCursor}
rotation={bobCursor.rotation}
/>
</MarkerCursorSlot>
<MarkerLabelBottom>
<BobLabel>
<MarkerText>Bob</MarkerText>
</BobLabel>
</MarkerLabelBottom>
</AnchoredMarkerGroup>
) : null}
<TablePanel>
<ViewRow>
<ViewSwitcher>
<ViewSwitcherLeft>
<ViewSwitcherIcon>
<IconList
<TablePanel $active={active}>
<ViewRow>
<ViewSwitcher>
<ViewSwitcherLeft>
<ViewSwitcherIcon>
<IconList
aria-hidden
color={COLORS.textSecondary}
size={16}
stroke={TABLER_STROKE}
/>
</ViewSwitcherIcon>
<ViewLabel>All</ViewLabel>
<ViewDot />
<ViewCount>{viewCount}</ViewCount>
</ViewSwitcherLeft>
<ViewSwitcherChevron>
<IconChevronDown
aria-hidden
color={COLORS.textSecondary}
size={16}
color={COLORS.textLight}
size={14}
stroke={TABLER_STROKE}
/>
</ViewSwitcherIcon>
<ViewLabel>All</ViewLabel>
<ViewDot />
<ViewCount>9</ViewCount>
</ViewSwitcherLeft>
<ViewSwitcherChevron>
<IconChevronDown
aria-hidden
color={COLORS.textLight}
size={14}
stroke={TABLER_STROKE}
/>
</ViewSwitcherChevron>
</ViewSwitcher>
</ViewRow>
</ViewSwitcherChevron>
</ViewSwitcher>
</ViewRow>
<FilterRow>
<FilterChip>
<FilterChipLabel>
<FilterChipIcon>
<IconCheckbox
<FilterRow>
<FilterChip ref={typeFilterRef}>
<FilterChipLabel>
<FilterChipIcon>
<IconHeartHandshake
aria-hidden
color={COLORS.blue}
size={14}
stroke={FILTER_ICON_STROKE}
/>
</FilterChipIcon>
<FilterName>Type</FilterName>
</FilterChipLabel>
<FilterValue>is Customer</FilterValue>
<FilterCloseButton type="button">
<IconX
aria-hidden
color={COLORS.blue}
size={14}
stroke={FILTER_ICON_STROKE}
/>
</FilterChipIcon>
<FilterName>Type</FilterName>
</FilterChipLabel>
<FilterValue>is Customer</FilterValue>
<FilterCloseButton type="button">
<IconX
aria-hidden
color={COLORS.blue}
size={14}
stroke={FILTER_ICON_STROKE}
/>
</FilterCloseButton>
</FilterChip>
</FilterCloseButton>
</FilterChip>
<FilterChip>
<FilterChipLabel>
<FilterChipMotion
ref={employeesFilterRef}
$removing={isEmployeesFilterRemoving}
$visible={isEmployeesFilterVisible}
>
<FilterChip
$pressed={phase === 'remove-filter'}
$removing={isEmployeesFilterRemoving}
>
<FilterChipLabel>
<FilterChipIcon>
<IconUser
aria-hidden
color={COLORS.blue}
size={14}
stroke={FILTER_ICON_STROKE}
/>
</FilterChipIcon>
<FilterName>Employees</FilterName>
</FilterChipLabel>
<FilterValue>{'>500'}</FilterValue>
<FilterCloseButton
$pressed={phase === 'remove-filter'}
type="button"
>
<IconX
aria-hidden
color={COLORS.blue}
size={14}
stroke={FILTER_ICON_STROKE}
/>
</FilterCloseButton>
</FilterChip>
</FilterChipMotion>
<FloatingAddFilter
$animated={addFilterLefts !== null}
$left={addFilterLeft}
type="button"
>
<FilterChipIcon>
<IconCheckbox
<IconPlus
aria-hidden
color={COLORS.blue}
color={COLORS.muted}
size={14}
stroke={FILTER_ICON_STROKE}
/>
</FilterChipIcon>
<FilterName>Employees</FilterName>
</FilterChipLabel>
<FilterValue>{'>500'}</FilterValue>
<FilterCloseButton type="button">
<IconX
aria-hidden
color={COLORS.blue}
size={14}
stroke={FILTER_ICON_STROKE}
/>
</FilterCloseButton>
</FilterChip>
Add filter
</FloatingAddFilter>
</FilterRow>
<AddFilter type="button">
<FilterChipIcon>
<IconPlus
aria-hidden
color={COLORS.muted}
size={14}
stroke={FILTER_ICON_STROKE}
/>
</FilterChipIcon>
Add filter
</AddFilter>
</FilterRow>
<TableBodyArea>
<LiveDataHeroTable
editedStatusLabel={isFirstTagRenamed ? typedTagLabel : ''}
isFirstTagEdited={isFirstTagEdited}
isFirstTagHoveredByAlice={isFirstTagHoveredByAlice}
showExtendedRows={hasEmployeesFilterBeenRemoved}
/>
</TableBodyArea>
</TablePanel>
<TableBodyArea>
<LiveDataHeroTable
editedStatusLabel={isFirstTagRenamed ? typedTagLabel : ''}
isFirstTagEdited={isFirstTagEdited}
isFirstTagHoveredByAlice={isFirstTagHoveredByAlice}
/>
</TableBodyArea>
</TablePanel>
<TomMarker
onPointerEnter={() => setIsTomHovered(true)}
onPointerLeave={() => setIsTomHovered(false)}
>
<TomMarkerMotion $hovered={isTomHovered}>
<TomArrow>
<TomCursor />
</TomArrow>
<TomLabel>
<MarkerText>Tom</MarkerText>
</TomLabel>
</TomMarkerMotion>
</TomMarker>
<TomMarker
onPointerEnter={() => setIsTomHovered(true)}
onPointerLeave={() => setIsTomHovered(false)}
>
<TomMarkerMotion $hovered={isTomHovered}>
<TomArrow>
<TomCursor />
</TomArrow>
<TomLabel>
<MarkerText>Tom</MarkerText>
</TomLabel>
</TomMarkerMotion>
</TomMarker>
<BobMarker
onPointerEnter={() => setIsBobHovered(true)}
onPointerLeave={() => setIsBobHovered(false)}
>
<BobMarkerMotion $hovered={isBobHovered}>
<BobArrow>
<MarkerCursor color={COLORS.purple} rotation={135} />
</BobArrow>
<BobLabel>
<MarkerText>Bob</MarkerText>
</BobLabel>
</BobMarkerMotion>
</BobMarker>
<BobMarker
$hidden={isBobCursorVisible}
onPointerEnter={() => setIsBobHovered(true)}
onPointerLeave={() => setIsBobHovered(false)}
>
<BobMarkerMotion $hovered={isBobHovered}>
<BobArrow>
<MarkerCursor color={COLORS.bobCursor} rotation={135} />
</BobArrow>
<BobLabel $withTopMargin>
<MarkerText>Bob</MarkerText>
</BobLabel>
</BobMarkerMotion>
</BobMarker>
</SceneContent>
</SceneFrame>
</SceneViewport>
</VisualRoot>
@@ -2,6 +2,7 @@
import { ThreeCardsScrollLayoutEffect } from '@/sections/ThreeCards/effect-components/ThreeCardsScrollLayoutEffect';
import { ThreeCardsFeatureCardType } from '@/sections/ThreeCards/types';
import { type ThreeCardsScrollLayoutOptions } from '@/sections/ThreeCards/utils/three-cards-scroll-layout';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import { useRef } from 'react';
@@ -23,6 +24,14 @@ const CardSlot = styled.div`
will-change: transform, opacity;
`;
const FEATURE_CARDS_SCROLL_LAYOUT_OPTIONS: ThreeCardsScrollLayoutOptions = {
stagger: 0.12,
endEdgeRatio: 0.3,
initialTranslateY: 120,
initialScale: 0.94,
opacityRamp: 0.25,
};
type FeatureCardsProps = { featureCards: ThreeCardsFeatureCardType[] };
export function FeatureCards({ featureCards }: FeatureCardsProps) {
@@ -35,6 +44,7 @@ export function FeatureCards({ featureCards }: FeatureCardsProps) {
cardCount={featureCards.length}
cardRefs={cardRefs}
gridRef={gridRef}
layoutOptions={FEATURE_CARDS_SCROLL_LAYOUT_OPTIONS}
/>
{featureCards.map((featureCard, index) => (
<CardSlot
@@ -5,16 +5,19 @@ import { useEffect } from 'react';
import {
applyThreeCardsScrollLayout,
type ThreeCardsScrollLayoutRefs,
type ThreeCardsScrollLayoutOptions,
} from '@/sections/ThreeCards/utils/three-cards-scroll-layout';
type ThreeCardsScrollLayoutEffectProps = ThreeCardsScrollLayoutRefs & {
cardCount: number;
layoutOptions?: ThreeCardsScrollLayoutOptions;
};
export function ThreeCardsScrollLayoutEffect({
cardCount,
cardRefs,
gridRef,
layoutOptions,
}: ThreeCardsScrollLayoutEffectProps) {
useEffect(() => {
const refs: ThreeCardsScrollLayoutRefs = { cardRefs, gridRef };
@@ -23,7 +26,7 @@ export function ThreeCardsScrollLayoutEffect({
const flushLayout = () => {
rafId = null;
applyThreeCardsScrollLayout(refs, cardCount);
applyThreeCardsScrollLayout(refs, cardCount, layoutOptions);
};
const scheduleLayout = () => {
@@ -33,7 +36,7 @@ export function ThreeCardsScrollLayoutEffect({
rafId = window.requestAnimationFrame(flushLayout);
};
applyThreeCardsScrollLayout(refs, cardCount);
applyThreeCardsScrollLayout(refs, cardCount, layoutOptions);
window.addEventListener('scroll', scheduleLayout, { passive: true });
window.addEventListener('resize', scheduleLayout);
return () => {
@@ -43,7 +46,7 @@ export function ThreeCardsScrollLayoutEffect({
window.cancelAnimationFrame(rafId);
}
};
}, [cardCount, cardRefs, gridRef]);
}, [cardCount, cardRefs, gridRef, layoutOptions]);
return null;
}
@@ -5,6 +5,8 @@ import type { ImageType } from '@/design-system/components/Image/types/Image';
type ThreeCardsFeatureCardBaseType = {
heading: HeadingType;
body: BodyType;
backgroundImageSrc?: string;
backgroundImageRotationDeg?: number;
icon: 'fast-path' | 'live-data' | 'users-group';
};
@@ -15,12 +15,35 @@ export type ThreeCardsScrollLayoutRefs = {
gridRef: RefObject<HTMLDivElement | null>;
};
const STAGGER = 0.25;
export type ThreeCardsScrollLayoutOptions = {
endEdgeRatio?: number;
initialScale?: number;
initialTranslateY?: number;
opacityRamp?: number;
stagger?: number;
};
const DEFAULT_SCROLL_LAYOUT_OPTIONS: Required<ThreeCardsScrollLayoutOptions> = {
endEdgeRatio: 0.2,
initialScale: 0.88,
initialTranslateY: 200,
opacityRamp: 0.4,
stagger: 0.25,
};
export function applyThreeCardsScrollLayout(
refs: ThreeCardsScrollLayoutRefs,
cardCount: number,
options: ThreeCardsScrollLayoutOptions = {},
): void {
const {
endEdgeRatio,
initialScale,
initialTranslateY,
opacityRamp,
stagger,
} = { ...DEFAULT_SCROLL_LAYOUT_OPTIONS, ...options };
const grid = refs.gridRef.current;
if (!grid) {
return;
@@ -46,7 +69,7 @@ export function applyThreeCardsScrollLayout(
const viewportHeight = window.innerHeight;
const startEdge = viewportHeight;
const endEdge = viewportHeight * 0.2;
const endEdge = viewportHeight * endEdgeRatio;
const progress = clamp01((startEdge - rect.top) / (startEdge - endEdge));
for (let index = 0; index < cardCount; index++) {
@@ -55,13 +78,15 @@ export function applyThreeCardsScrollLayout(
continue;
}
const delay = index * STAGGER;
const localProgress = clamp01((progress - delay) / (1 - delay));
const delay = index * stagger;
const localProgress = clamp01(
(progress - delay) / Math.max(1 - delay, Number.EPSILON),
);
const eased = easeOutQuint(localProgress);
const translateY = (1 - eased) * 200;
const scale = 0.88 + eased * 0.12;
const opacity = clamp01(localProgress / 0.4);
const translateY = (1 - eased) * initialTranslateY;
const scale = initialScale + eased * (1 - initialScale);
const opacity = clamp01(localProgress / opacityRamp);
node.style.opacity = String(opacity);
node.style.transform = `translateY(${translateY}px) scale(${scale})`;