## Summary
- refresh the partner hero visual and testimonial presentation,
including the partner-specific carousel and illustration assets
- switch the testimonials top notch to the masked rendering approach
used elsewhere for more precise shape control
- extend halftone studio/export support and related geometry/state
handling used by the updated partner visuals
- include supporting website UI adjustments across navigation, pricing,
plans, and Salesforce-related sections

## Testing
- Not run (not requested)
This commit is contained in:
Thomas des Francs
2026-04-13 16:36:03 +02:00
committed by GitHub
parent 84b325876d
commit 12233e6c47
15 changed files with 1338 additions and 349 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

@@ -1645,9 +1645,9 @@ export function HalftoneCanvas({
}
if (!isImageMode) {
let baseRotationX = 0;
let baseRotationY = 0;
let baseRotationZ = 0;
let baseRotationX = initialPoseReference.current?.rotationX ?? 0;
let baseRotationY = initialPoseReference.current?.rotationY ?? 0;
let baseRotationZ = initialPoseReference.current?.rotationZ ?? 0;
let meshOffsetY = 0;
let meshScale = 1;
let lightAngle = activeSettings.lighting.angleDegrees;
@@ -54,8 +54,6 @@ describe('halftone export naming', () => {
expect(
normalizeHalftoneStudioSettings(parsed.settings).halftone.hoverDashColor,
).toBe(
DEFAULT_HALFTONE_SETTINGS.halftone.hoverDashColor,
);
).toBe(DEFAULT_HALFTONE_SETTINGS.halftone.hoverDashColor);
});
});
@@ -2930,8 +2930,7 @@ export function generateReactComponent(
modelFilenameOverride,
);
const pose = normalizeExportPose(initialPose);
const normalizedComponentName =
normalizeExportComponentName(componentName);
const normalizedComponentName = normalizeExportComponentName(componentName);
const defaultModelUrl =
modelFilenameOverride ?? shape.filename ?? 'model.glb';
const defaultImageUrl = imageFilename ?? 'image.png';
@@ -3064,8 +3063,7 @@ export async function generateStandaloneHtml(
modelFilenameOverride,
);
const pose = normalizeExportPose(initialPose);
const normalizedComponentName =
normalizeExportComponentName(componentName);
const normalizedComponentName = normalizeExportComponentName(componentName);
const defaultImageUrl = imageFilename ?? 'image.png';
const embeddedImportedModelUrl =
!isImageMode && shape.kind === 'imported' && importedFile
@@ -273,6 +273,26 @@ async function loadImportedGeometry(
return parseGlbGeometry(buffer, '', label);
}
export async function loadImportedGeometryFromUrl(
loader: HalftoneModelLoader,
modelUrl: string,
label: string,
) {
const response = await fetch(modelUrl);
if (!response.ok) {
throw new Error(`Unable to load ${label} from ${modelUrl}.`);
}
const buffer = await response.arrayBuffer();
if (loader === 'fbx') {
return parseFbxGeometry(buffer, '', label);
}
return parseGlbGeometry(buffer, '', label);
}
function makePolarShape(
radiusFunction: (angle: number) => number,
segments = 320,
@@ -405,9 +405,10 @@ export const LEGACY_HALFTONE_SETTING_KEYS = [
'shadowCrush',
] as const;
export function isRoundedBandHalftoneSettings(
value: unknown,
): value is Omit<HalftoneEffectSettings, 'hoverDashColor'> & {
export function isRoundedBandHalftoneSettings(value: unknown): value is Omit<
HalftoneEffectSettings,
'hoverDashColor'
> & {
hoverDashColor?: string;
} {
if (!value || typeof value !== 'object') {
@@ -111,15 +111,17 @@ export default async function PartnerPage() {
</ThreeCards.Root>
<Testimonials.Root
backgroundColor={theme.colors.secondary.background[5]}
color={theme.colors.primary.text[100]}
backgroundColor={theme.colors.secondary.background[100]}
color={theme.colors.secondary.text[100]}
shapeBodyFillColor={theme.colors.secondary.background[100]}
shapeFillColor={theme.colors.primary.background[100]}
>
<Testimonials.Carousel
<Testimonials.PartnerCarousel
eyebrow={TESTIMONIALS_DATA.eyebrow}
testimonials={TESTIMONIALS_DATA.testimonials}
>
<Testimonials.PartnerVisual />
</Testimonials.Carousel>
</Testimonials.PartnerCarousel>
</Testimonials.Root>
<Signoff.Root
@@ -1,129 +1,120 @@
'use client';
import { HalftoneCanvas } from '@/app/halftone/_components/HalftoneCanvas';
import { loadImportedGeometryFromUrl } from '@/app/halftone/_lib/geometry-registry';
import type {
HalftoneExportPose,
HalftoneStudioSettings,
} from '@/app/halftone/_lib/state';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import { useLayoutEffect, useRef } from 'react';
import { useEffect, useState } from 'react';
import * as THREE from 'three';
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
const GLB_URL = '/illustrations/partner/testimonials/quote.glb';
const PARTNER_QUOTE_LABEL = 'partner quote';
const PREVIEW_DISTANCE = 6;
const deg = THREE.MathUtils.degToRad;
const scanlineVertexShader = /* glsl */ `
varying vec3 vWorldPosition;
varying vec3 vWorldNormal;
void main() {
vec4 worldPosition = modelMatrix * vec4(position, 1.0);
vWorldPosition = worldPosition.xyz;
vWorldNormal = normalize(mat3(modelMatrix) * normal);
gl_Position = projectionMatrix * viewMatrix * worldPosition;
}
`;
const scanlineFragmentShader = /* glsl */ `
uniform vec3 uColor;
uniform vec3 uLightDir;
uniform float uStripeScale;
varying vec3 vWorldPosition;
varying vec3 vWorldNormal;
void main() {
vec3 normal = normalize(vWorldNormal);
vec3 lightDir = normalize(uLightDir);
float ndotl = max(dot(normal, lightDir), 0.06);
float y = vWorldPosition.y * uStripeScale;
float cell = fract(y);
float shadowWeight = mix(1.0, 0.5, ndotl);
float lineWidth = 0.58 * shadowWeight;
float edge = 0.035;
float band = 1.0 - smoothstep(lineWidth, lineWidth + edge, cell);
float highlight = pow(ndotl, 1.35);
float dash = fract(vWorldPosition.x * 20.0 + vWorldPosition.z * 6.0);
float dashMask = mix(
1.0,
smoothstep(0.15, 0.45, dash) * (1.0 - smoothstep(0.55, 0.88, dash)),
highlight
);
band *= dashMask;
float speckle = fract(
sin(dot(vWorldPosition.xz, vec2(127.1, 311.7))) * 43758.5453
);
band *= mix(1.0, 0.55 + 0.45 * step(0.4, speckle), highlight * 0.85);
if (band < 0.015) {
discard;
}
vec3 lit = uColor * mix(0.72, 1.18, ndotl);
gl_FragColor = vec4(lit, band);
}
`;
function createScanlineMaterial(lightDirection: THREE.Vector3) {
return new THREE.ShaderMaterial({
uniforms: {
uColor: { value: new THREE.Color('#1e5bff') },
uLightDir: { value: lightDirection.clone() },
uStripeScale: { value: 16.0 },
},
vertexShader: scanlineVertexShader,
fragmentShader: scanlineFragmentShader,
const PARTNER_QUOTE_SETTINGS: HalftoneStudioSettings = {
sourceMode: 'shape',
shapeKey: 'partnerTestimonialsQuote',
lighting: {
intensity: 1.5,
fillIntensity: 0.15,
ambientIntensity: 0.08,
angleDegrees: 45,
height: 2,
},
material: {
surface: 'solid',
color: '#d4d0c8',
roughness: 0.42,
metalness: 0.16,
thickness: 150,
refraction: 2,
environmentPower: 5,
},
halftone: {
enabled: true,
scale: 24.72,
power: -0.07,
width: 0.46,
imageContrast: 1,
dashColor: '#4A38F5',
hoverDashColor: '#4A38F5',
},
background: {
transparent: true,
depthWrite: true,
depthTest: true,
side: THREE.DoubleSide,
});
}
function disposeObjectSubtree(root: THREE.Object3D) {
root.traverse((sceneObject) => {
if (!(sceneObject instanceof THREE.Mesh)) {
return;
}
sceneObject.geometry?.dispose();
const material = sceneObject.material;
if (Array.isArray(material)) {
material.forEach((item) => item.dispose());
} else {
material?.dispose();
}
});
}
type MeshRestPose = {
position: THREE.Vector3;
quaternion: THREE.Quaternion;
wobblePhase: number;
color: 'transparent',
},
animation: {
autoRotateEnabled: false,
breatheEnabled: true,
cameraParallaxEnabled: false,
followHoverEnabled: true,
followDragEnabled: false,
floatEnabled: false,
hoverHalftoneEnabled: false,
hoverLightEnabled: false,
dragFlowEnabled: false,
lightSweepEnabled: false,
rotateEnabled: false,
autoSpeed: 0.2,
autoWobble: 0.3,
breatheAmount: 0.02,
breatheSpeed: 0.2,
cameraParallaxAmount: 0.3,
cameraParallaxEase: 0.08,
driftAmount: 8,
hoverRange: 8,
hoverEase: 0.02,
hoverReturn: true,
dragSens: 0.008,
dragFriction: 0.08,
dragMomentum: true,
rotateAxis: '-y',
rotatePreset: 'axis',
rotateSpeed: 0.2,
rotatePingPong: false,
floatAmplitude: 0.16,
floatSpeed: 0.8,
lightSweepHeightRange: 0.25,
lightSweepRange: 5,
lightSweepSpeed: 0.55,
springDamping: 0.72,
springReturnEnabled: true,
springStrength: 0.06,
hoverHalftonePowerShift: 0.42,
hoverHalftoneRadius: 0.2,
hoverHalftoneWidthShift: -0.18,
hoverLightIntensity: 0.8,
hoverLightRadius: 0.2,
dragFlowDecay: 0.08,
dragFlowRadius: 0.24,
dragFlowStrength: 1.8,
hoverWarpStrength: 3,
hoverWarpRadius: 0.15,
dragWarpStrength: 5,
waveEnabled: false,
waveSpeed: 1,
waveAmount: 2,
},
};
function applyScanlineMaterials(
modelRoot: THREE.Object3D,
lightDirection: THREE.Vector3,
) {
modelRoot.traverse((sceneObject) => {
if (!(sceneObject instanceof THREE.Mesh)) {
return;
}
const PARTNER_QUOTE_INITIAL_POSE: HalftoneExportPose = {
autoElapsed: 126.7357000006041,
rotateElapsed: 10.021299999999943,
rotationX: deg(-80),
rotationY: deg(10),
rotationZ: deg(350),
targetRotationX: 0,
targetRotationY: 0,
timeElapsed: 289.53700000066755,
};
sceneObject.material = createScanlineMaterial(lightDirection);
const mesh = sceneObject;
const rest: MeshRestPose = {
position: mesh.position.clone(),
quaternion: mesh.quaternion.clone(),
wobblePhase: mesh.position.y * 4.2 + mesh.position.x * 1.7,
};
mesh.userData.partnerTestimonialsVisualRest = rest;
});
}
const noopFirstInteraction = () => {};
const noopPoseChange = (_pose: HalftoneExportPose) => {};
const VisualFrame = styled.div`
background-color: transparent;
@@ -139,214 +130,56 @@ const VisualFrame = styled.div`
}
`;
const CanvasMount = styled.div`
const VisualPlaceholder = styled.div`
display: block;
height: 100%;
inset: 0;
min-width: 0;
position: absolute;
width: 100%;
`;
export function Partner() {
const mountReference = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
const container = mountReference.current;
if (!container) {
return;
}
const [geometry, setGeometry] = useState<THREE.BufferGeometry | null>(null);
useEffect(() => {
let cancelled = false;
let animationFrameId = 0;
let loadedGeometry: THREE.BufferGeometry | null = null;
const pointer = { x: 0, y: 0, inside: false };
const targetRotation = { x: 0, y: 0 };
const lightDirectionWorld = new THREE.Vector3(4, 8, 6).normalize();
const scene = new THREE.Scene();
const width = container.clientWidth;
const height = container.clientHeight;
const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 100);
camera.position.set(0, 0, 5.05);
const renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(width, height);
renderer.setClearColor(0x000000, 0);
renderer.outputColorSpace = THREE.SRGBColorSpace;
const canvas = renderer.domElement;
canvas.style.cursor = 'pointer';
canvas.style.display = 'block';
canvas.style.height = '100%';
canvas.style.touchAction = 'none';
canvas.style.width = '100%';
container.appendChild(canvas);
const pivot = new THREE.Group();
scene.add(pivot);
const clock = new THREE.Clock();
const dracoLoader = new DRACOLoader();
dracoLoader.setDecoderPath(
'https://www.gstatic.com/draco/versioned/decoders/1.5.6/',
);
const loader = new GLTFLoader();
loader.setDRACOLoader(dracoLoader);
loader.load(
GLB_URL,
(gltf) => {
void loadImportedGeometryFromUrl('glb', GLB_URL, PARTNER_QUOTE_LABEL).then(
(nextGeometry) => {
if (cancelled) {
disposeObjectSubtree(gltf.scene);
nextGeometry.dispose();
return;
}
const modelRoot = gltf.scene;
const bounds = new THREE.Box3().setFromObject(modelRoot);
const center = bounds.getCenter(new THREE.Vector3());
const size = bounds.getSize(new THREE.Vector3());
const maxAxis = Math.max(size.x, size.y, size.z, 0.001);
const scale = 2.85 / maxAxis;
modelRoot.position.sub(center);
modelRoot.scale.setScalar(scale);
applyScanlineMaterials(modelRoot, lightDirectionWorld);
pivot.add(modelRoot);
const renderFrame = () => {
if (cancelled) {
return;
}
animationFrameId = window.requestAnimationFrame(renderFrame);
const delta = Math.min(clock.getDelta(), 0.1);
const rotationDamp = 6.8;
const influence = pointer.inside ? 1 : 0.38;
targetRotation.y = pointer.x * 0.78 * influence;
targetRotation.x = pointer.y * 0.62 * influence;
pivot.rotation.y = THREE.MathUtils.damp(
pivot.rotation.y,
targetRotation.y,
rotationDamp,
delta,
);
pivot.rotation.x = THREE.MathUtils.damp(
pivot.rotation.x,
targetRotation.x,
rotationDamp,
delta,
);
const hoverLift = pointer.inside ? 1 : 0;
pivot.scale.setScalar(
THREE.MathUtils.damp(pivot.scale.x, 1 + hoverLift * 0.12, 7, delta),
);
const mx = pointer.x * (pointer.inside ? 1 : 0.32);
const my = pointer.y * (pointer.inside ? 1 : 0.32);
modelRoot.traverse((sceneObject) => {
if (!(sceneObject instanceof THREE.Mesh)) {
return;
}
const rest = sceneObject.userData.partnerTestimonialsVisualRest as
| MeshRestPose
| undefined;
if (!rest) {
return;
}
const phase = rest.wobblePhase;
const wobble = pointer.inside ? 1 : 0.36;
sceneObject.position.x =
rest.position.x + mx * 0.22 * Math.sin(phase * 1.8);
sceneObject.position.z =
rest.position.z + my * 0.19 * Math.cos(phase * 1.4);
sceneObject.position.y =
rest.position.y + (mx + my) * 0.055 * Math.sin(phase * 2.5);
const twist = (mx * 0.34 + my * 0.24) * wobble * Math.sin(phase);
sceneObject.quaternion.copy(rest.quaternion);
sceneObject.rotateY(twist);
});
renderer.render(scene, camera);
};
renderFrame();
loadedGeometry = nextGeometry;
setGeometry(nextGeometry);
},
(error) => {
console.error(error);
},
undefined,
undefined,
);
const setPointerFromEvent = (event: PointerEvent) => {
const rect = canvas.getBoundingClientRect();
const normalizedX = ((event.clientX - rect.left) / rect.width) * 2 - 1;
const normalizedY = -(((event.clientY - rect.top) / rect.height) * 2 - 1);
pointer.x = THREE.MathUtils.clamp(normalizedX, -1, 1);
pointer.y = THREE.MathUtils.clamp(normalizedY, -1, 1);
};
const handlePointerEnter = () => {
pointer.inside = true;
};
const handlePointerLeave = () => {
pointer.inside = false;
pointer.x = 0;
pointer.y = 0;
};
const handlePointerMove = (event: PointerEvent) => {
setPointerFromEvent(event);
};
canvas.addEventListener('pointerenter', handlePointerEnter);
canvas.addEventListener('pointerleave', handlePointerLeave);
canvas.addEventListener('pointermove', handlePointerMove);
const handleResize = () => {
if (!mountReference.current || cancelled) {
return;
}
const nextWidth = mountReference.current.clientWidth;
const nextHeight = mountReference.current.clientHeight;
camera.aspect = nextWidth / nextHeight;
camera.updateProjectionMatrix();
renderer.setSize(nextWidth, nextHeight);
};
window.addEventListener('resize', handleResize);
return () => {
cancelled = true;
window.removeEventListener('resize', handleResize);
canvas.removeEventListener('pointerenter', handlePointerEnter);
canvas.removeEventListener('pointerleave', handlePointerLeave);
canvas.removeEventListener('pointermove', handlePointerMove);
window.cancelAnimationFrame(animationFrameId);
disposeObjectSubtree(scene);
renderer.dispose();
dracoLoader.dispose();
if (canvas.parentNode === container) {
container.removeChild(canvas);
}
loadedGeometry?.dispose();
};
}, []);
return (
<VisualFrame>
<CanvasMount aria-hidden ref={mountReference} />
{geometry ? (
<HalftoneCanvas
geometry={geometry}
imageElement={null}
initialPose={PARTNER_QUOTE_INITIAL_POSE}
onFirstInteraction={noopFirstInteraction}
onPoseChange={noopPoseChange}
previewDistance={PREVIEW_DISTANCE}
settings={PARTNER_QUOTE_SETTINGS}
/>
) : (
<VisualPlaceholder aria-hidden />
)}
</VisualFrame>
);
}
@@ -0,0 +1,762 @@
'use client';
import { styled } from '@linaria/react';
import { useEffect, useRef } from 'react';
import * as THREE from 'three';
const PREVIEW_DISTANCE = 3.2;
const SOURCE_PREVIEW_DISTANCE = 6.1;
const REFERENCE_PREVIEW_DISTANCE = 4;
const VIRTUAL_RENDER_HEIGHT = 768;
const HALFTONE_TILE_SIZE = 10;
const HALFTONE_POWER = -0.1;
const HALFTONE_WIDTH = 0.45;
const HALFTONE_CONTRAST = 1;
const HALFTONE_DASH_COLOR = '#959595';
const HALFTONE_HOVER_COLOR = '#4A38F5';
const HALFTONE_HOVER_RADIUS = 0.6;
const HALFTONE_HOVER_POWER_SHIFT = 0.2;
const HALFTONE_HOVER_WIDTH_SHIFT = -0.18;
const HALFTONE_HOVER_LIGHT_INTENSITY = 0;
const HALFTONE_HOVER_LIGHT_RADIUS = 0.2;
const IMAGE_POINTER_FOLLOW = 0.38;
const IMAGE_POINTER_VELOCITY_DAMPING = 0.82;
const MIN_FOOTPRINT_SCALE = 0.001;
const passThroughVertexShader = `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = vec4(position, 1.0);
}
`;
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.y = (uv.y - 0.5) / scale + 0.5;
} else {
float scale = imageAspect / viewAspect;
uv.x = (uv.x - 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);
}
`;
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 time;
uniform float waveAmount;
uniform float waveSpeed;
uniform float footprintScale;
uniform vec2 interactionUv;
uniform vec2 interactionVelocity;
uniform vec2 dragOffset;
uniform float hoverHalftoneActive;
uniform float hoverHalftonePowerShift;
uniform float hoverHalftoneRadius;
uniform float hoverHalftoneWidthShift;
uniform float hoverLightStrength;
uniform float hoverLightRadius;
uniform float hoverFlowStrength;
uniform float hoverFlowRadius;
uniform float dragFlowStrength;
uniform float cropToBounds;
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() {
if (cropToBounds > 0.5) {
vec4 boundsCheck = texture2D(tScene, vUv);
if (boundsCheck.a < 0.01) {
gl_FragColor = vec4(0.0);
return;
}
}
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);
}
float hoverHalftoneMask = 0.0;
if (hoverHalftoneActive > 0.0) {
float hoverHalftoneRadiusPx = hoverHalftoneRadius * logicalResolution.y;
hoverHalftoneMask = smoothstep(hoverHalftoneRadiusPx, 0.0, fragDist);
}
float hoverFlowMask = 0.0;
if (hoverFlowStrength > 0.0) {
float hoverRadiusPx = hoverFlowRadius * logicalResolution.y;
hoverFlowMask = smoothstep(hoverRadiusPx, 0.0, fragDist);
}
vec2 hoverDisplacement =
radialDir * hoverFlowStrength * hoverFlowMask * halftoneSize * 0.55 +
motionDir * hoverFlowStrength * hoverFlowMask * (0.4 + motionBias) * halftoneSize * 1.15;
vec2 travelDisplacement = dragOffset * dragFlowStrength * 0.45;
vec2 effectCoord = fragCoord + hoverDisplacement + travelDisplacement;
float bandRow = floor(effectCoord.y / halftoneSize);
float waveOffset =
waveAmount * sin(time * waveSpeed + bandRow * 0.5) * halftoneSize;
effectCoord.x += waveOffset;
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 localPower = clamp(
s_3 + hoverHalftonePowerShift * hoverHalftoneMask,
-1.5,
1.5
);
float localWidth = clamp(
s_4 + hoverHalftoneWidthShift * hoverHalftoneMask,
0.05,
1.4
);
float lightLift =
hoverLightStrength * hoverLightMask * mix(0.78, 1.18, motionBias) * 0.22;
float bandRadius = clamp(
(
(
sceneSample.r +
sceneSample.g +
sceneSample.b +
localPower * 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, localWidth);
float edge = 0.02;
alpha = (1.0 - smoothstep(0.0, edge, signedDistance)) * mask;
}
vec3 activeDashColor = mix(dashColor, hoverDashColor, hoverHalftoneMask);
vec3 color = activeDashColor * alpha;
gl_FragColor = vec4(color, alpha);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}
`;
const OverlayMount = styled.div`
inset: 0;
position: absolute;
z-index: 1;
`;
type PointerState = {
mouseX: number;
mouseY: number;
pointerInside: boolean;
pointerVelocityX: number;
pointerVelocityY: number;
smoothedMouseX: number;
smoothedMouseY: number;
};
function clampRectToViewport(
rect: { height: number; width: number; x: number; y: number },
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 {
height: maxY - minY,
width: maxX - minX,
x: minX,
y: minY,
};
}
function getRectArea(rect: { height: number; width: number } | 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(
{
height: scaledHeight,
width: scaledWidth,
x: (viewportWidth - scaledWidth) * 0.5,
y: (viewportHeight - scaledHeight) * 0.5,
},
viewportWidth,
viewportHeight,
);
}
function getFootprintScaleFromRects(
currentRect: { height: number; width: number } | null,
referenceRect: { height: number; width: number } | 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 getRelativeImageScale({
imageHeight,
imageWidth,
previewDistance,
referencePreviewDistance,
viewportHeight,
viewportWidth,
}: {
imageHeight: number;
imageWidth: number;
previewDistance: number;
referencePreviewDistance: number;
viewportHeight: number;
viewportWidth: number;
}) {
const currentRect = getContainedImageRect({
imageHeight,
imageWidth,
viewportHeight,
viewportWidth,
zoom: getImagePreviewZoom(previewDistance),
});
const referenceRect = getContainedImageRect({
imageHeight,
imageWidth,
viewportHeight,
viewportWidth,
zoom: getImagePreviewZoom(referencePreviewDistance),
});
return getFootprintScaleFromRects(currentRect, referenceRect);
}
function createRenderTarget(width: number, height: number) {
return new THREE.WebGLRenderTarget(width, height, {
format: THREE.RGBAFormat,
magFilter: THREE.LinearFilter,
minFilter: THREE.LinearFilter,
});
}
function loadImage(imageUrl: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const image = new Image();
image.decoding = 'async';
image.onload = () => resolve(image);
image.onerror = () =>
reject(new Error(`Failed to load hero image: ${imageUrl}`));
image.src = imageUrl;
});
}
async function mountHalftoneOverlay({
container,
imageUrl,
}: {
container: HTMLDivElement;
imageUrl: string;
}): Promise<() => void> {
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({
alpha: true,
antialias: false,
powerPreference: 'high-performance',
});
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.setPixelRatio(1);
renderer.setClearColor(0x000000, 0);
renderer.setSize(getVirtualWidth(), getVirtualHeight(), false);
const canvas = renderer.domElement;
canvas.setAttribute('aria-hidden', 'true');
canvas.style.cursor = 'crosshair';
canvas.style.display = 'block';
canvas.style.height = '100%';
canvas.style.width = '100%';
container.appendChild(canvas);
const imageTexture = new THREE.Texture(image);
imageTexture.colorSpace = THREE.SRGBColorSpace;
imageTexture.generateMipmaps = false;
imageTexture.magFilter = THREE.LinearFilter;
imageTexture.minFilter = THREE.LinearFilter;
imageTexture.needsUpdate = true;
const sceneTarget = 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({
fragmentShader: imagePassthroughFragmentShader,
uniforms: {
contrast: { value: HALFTONE_CONTRAST },
imageSize: { value: new THREE.Vector2(image.width, image.height) },
tImage: { value: imageTexture },
viewportSize: {
value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
},
zoom: { value: getImagePreviewZoom(PREVIEW_DISTANCE) },
},
vertexShader: passThroughVertexShader,
});
const imageScene = new THREE.Scene();
imageScene.add(new THREE.Mesh(fullScreenGeometry, imageMaterial));
const halftoneMaterial = new THREE.ShaderMaterial({
fragmentShader: halftoneFragmentShader,
transparent: true,
uniforms: {
cropToBounds: { value: 1 },
dashColor: { value: new THREE.Color(HALFTONE_DASH_COLOR) },
dragFlowStrength: { value: 0 },
dragOffset: { value: new THREE.Vector2(0, 0) },
effectResolution: {
value: new THREE.Vector2(getVirtualWidth(), getVirtualHeight()),
},
footprintScale: { value: 1 },
hoverDashColor: { value: new THREE.Color(HALFTONE_HOVER_COLOR) },
hoverFlowRadius: { value: 0.18 },
hoverFlowStrength: { value: 0 },
hoverHalftoneActive: { value: 0 },
hoverHalftonePowerShift: { value: 0 },
hoverHalftoneRadius: { value: HALFTONE_HOVER_RADIUS },
hoverHalftoneWidthShift: { value: 0 },
hoverLightRadius: { value: HALFTONE_HOVER_LIGHT_RADIUS },
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: HALFTONE_POWER },
s_4: { value: HALFTONE_WIDTH },
tScene: { value: sceneTarget.texture },
tile: { value: HALFTONE_TILE_SIZE },
time: { value: 0 },
waveAmount: { value: 0 },
waveSpeed: { value: 1 },
},
vertexShader: passThroughVertexShader,
});
const postScene = new THREE.Scene();
postScene.add(new THREE.Mesh(fullScreenGeometry, halftoneMaterial));
const updateViewportUniforms = ({
effectHeight,
effectWidth,
logicalHeight,
logicalWidth,
}: {
effectHeight: number;
effectWidth: number;
logicalHeight: number;
logicalWidth: number;
}) => {
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: PREVIEW_DISTANCE,
viewportHeight: getVirtualHeight(),
viewportWidth: getVirtualWidth(),
});
const getHoverScale = () =>
getRelativeImageScale({
imageHeight: image.height,
imageWidth: image.width,
previewDistance: PREVIEW_DISTANCE,
referencePreviewDistance: SOURCE_PREVIEW_DISTANCE,
viewportHeight: getVirtualHeight(),
viewportWidth: getVirtualWidth(),
});
const pointer: PointerState = {
mouseX: 0.5,
mouseY: 0.5,
pointerInside: false,
pointerVelocityX: 0,
pointerVelocityY: 0,
smoothedMouseX: 0.5,
smoothedMouseY: 0.5,
};
const syncSize = () => {
const virtualWidth = getVirtualWidth();
const virtualHeight = getVirtualHeight();
renderer.setSize(virtualWidth, virtualHeight, false);
sceneTarget.setSize(virtualWidth, virtualHeight);
updateViewportUniforms({
effectHeight: virtualHeight,
effectWidth: virtualWidth,
logicalHeight: virtualHeight,
logicalWidth: virtualWidth,
});
};
const resizeObserver = new ResizeObserver(syncSize);
resizeObserver.observe(container);
const updatePointerPosition = (
event: PointerEvent,
options?: { resetVelocity?: boolean },
) => {
const rect = canvas.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 - pointer.mouseX;
const deltaY = nextMouseY - pointer.mouseY;
pointer.mouseX = nextMouseX;
pointer.mouseY = nextMouseY;
pointer.pointerInside =
event.clientX >= rect.left &&
event.clientX <= rect.right &&
event.clientY >= rect.top &&
event.clientY <= rect.bottom;
if (options?.resetVelocity) {
pointer.pointerVelocityX = 0;
pointer.pointerVelocityY = 0;
pointer.smoothedMouseX = nextMouseX;
pointer.smoothedMouseY = nextMouseY;
return;
}
pointer.pointerVelocityX = deltaX;
pointer.pointerVelocityY = deltaY;
};
const handlePointerMove = (event: PointerEvent) => {
const resetVelocity = !pointer.pointerInside;
updatePointerPosition(
event,
resetVelocity ? { resetVelocity: true } : undefined,
);
};
const handlePointerLeave = () => {
pointer.pointerInside = false;
pointer.pointerVelocityX = 0;
pointer.pointerVelocityY = 0;
};
canvas.addEventListener('pointermove', handlePointerMove);
canvas.addEventListener('pointerleave', handlePointerLeave);
let animationFrameId = 0;
const renderFrame = (timestamp: number) => {
animationFrameId = window.requestAnimationFrame(renderFrame);
halftoneMaterial.uniforms.time.value = timestamp / 1000;
const hoverScale = getHoverScale();
pointer.smoothedMouseX +=
(pointer.mouseX - pointer.smoothedMouseX) * IMAGE_POINTER_FOLLOW;
pointer.smoothedMouseY +=
(pointer.mouseY - pointer.smoothedMouseY) * IMAGE_POINTER_FOLLOW;
pointer.pointerVelocityX *= IMAGE_POINTER_VELOCITY_DAMPING;
pointer.pointerVelocityY *= IMAGE_POINTER_VELOCITY_DAMPING;
halftoneMaterial.uniforms.interactionUv.value.set(
pointer.smoothedMouseX,
1 - pointer.smoothedMouseY,
);
halftoneMaterial.uniforms.interactionVelocity.value.set(
pointer.pointerVelocityX * getVirtualWidth(),
-pointer.pointerVelocityY * getVirtualHeight(),
);
halftoneMaterial.uniforms.hoverHalftoneActive.value = pointer.pointerInside
? 1
: 0;
halftoneMaterial.uniforms.hoverHalftonePowerShift.value =
pointer.pointerInside ? HALFTONE_HOVER_POWER_SHIFT : 0;
halftoneMaterial.uniforms.hoverHalftoneRadius.value =
HALFTONE_HOVER_RADIUS * hoverScale;
halftoneMaterial.uniforms.hoverHalftoneWidthShift.value =
pointer.pointerInside ? HALFTONE_HOVER_WIDTH_SHIFT : 0;
halftoneMaterial.uniforms.hoverLightStrength.value = pointer.pointerInside
? HALFTONE_HOVER_LIGHT_INTENSITY
: 0;
halftoneMaterial.uniforms.hoverLightRadius.value =
HALFTONE_HOVER_LIGHT_RADIUS * hoverScale;
halftoneMaterial.uniforms.footprintScale.value = getHalftoneScale();
imageMaterial.uniforms.zoom.value = getImagePreviewZoom(PREVIEW_DISTANCE);
renderer.setRenderTarget(sceneTarget);
renderer.render(imageScene, orthographicCamera);
renderer.setRenderTarget(null);
renderer.clear();
renderer.render(postScene, orthographicCamera);
};
renderFrame(0);
return () => {
window.cancelAnimationFrame(animationFrameId);
resizeObserver.disconnect();
canvas.removeEventListener('pointermove', handlePointerMove);
canvas.removeEventListener('pointerleave', handlePointerLeave);
halftoneMaterial.dispose();
imageMaterial.dispose();
imageTexture.dispose();
fullScreenGeometry.dispose();
sceneTarget.dispose();
renderer.dispose();
if (canvas.parentNode === container) {
container.removeChild(canvas);
}
};
}
type PartnerHalftoneOverlayProps = {
imageUrl: string;
};
export function PartnerHalftoneOverlay({
imageUrl,
}: PartnerHalftoneOverlayProps) {
const mountRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
return;
}
const container = mountRef.current;
if (!container) {
return;
}
let disposed = false;
const disposePromise = mountHalftoneOverlay({ container, imageUrl }).catch(
(error) => {
console.error(error);
return undefined;
},
);
return () => {
disposed = true;
void disposePromise.then((dispose) => {
if (!disposed) {
return;
}
dispose?.();
});
};
}, [imageUrl]);
return <OverlayMount ref={mountRef} />;
}
@@ -1,33 +1,28 @@
import { theme } from '@/theme';
import { css } from '@linaria/core';
import { styled } from '@linaria/react';
import NextImage from 'next/image';
import { PartnerHalftoneOverlay } from './PartnerHalftoneOverlay';
const HERO_IMAGE_URL = '/images/partner/hero/partners-hero.webp';
const StyledContainer = styled.div`
background-color: ${theme.colors.secondary.background[100]};
border-radius: ${theme.radius(1)};
height: 462px;
margin-top: ${theme.spacing(6)};
overflow: hidden;
position: relative;
width: 100%;
`;
const partnerHeroImageClassName = css`
object-fit: cover;
object-position: center;
@media (max-width: ${theme.breakpoints.md - 1}px) {
height: 360px;
}
`;
export function PartnerVisual() {
return (
<StyledContainer>
<NextImage
alt="Twenty partners"
className={partnerHeroImageClassName}
fill
priority
sizes="100vw"
src="/images/partner/hero/hero.png"
/>
<PartnerHalftoneOverlay imageUrl={HERO_IMAGE_URL} />
</StyledContainer>
);
}
@@ -1,11 +1,15 @@
const TESTIMONIALS_SHAPE_PATH =
'M0 4a4 4 0 0 1 4-4h344.32c4.197 0 8.369.66 12.361 1.958l49.5 16.084A40 40 0 0 0 422.542 20h517.7c4.293 0 8.559-.691 12.633-2.047l47.785-15.906A40 40 0 0 1 1013.29 0H1356a4 4 0 0 1 4 4v16H0z';
const TESTIMONIALS_SHAPE_CLIP_PATH =
'polygon(0% 20%, 0.294% 0%, 25.318% 0%, 31.069% 100%, 69.135% 100%, 74.507% 0%, 99.706% 0%, 100% 20%, 100% 100%, 0% 100%)';
interface TestimonialsShapeProps {
bodyFillColor?: string;
fillColor: string;
}
export function TestimonialsShape({ fillColor }: TestimonialsShapeProps) {
export function TestimonialsShape({
bodyFillColor,
fillColor,
}: TestimonialsShapeProps) {
return (
<div
aria-hidden
@@ -18,17 +22,18 @@ export function TestimonialsShape({ fillColor }: TestimonialsShapeProps) {
zIndex: -1,
}}
>
<svg
width="100%"
height="20"
viewBox="0 0 1360 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
preserveAspectRatio="none"
style={{ display: 'block' }}
>
<path d={TESTIMONIALS_SHAPE_PATH} fill={fillColor} />
</svg>
<div
style={{
backgroundColor: fillColor,
clipPath: TESTIMONIALS_SHAPE_CLIP_PATH,
height: 20,
left: 0,
overflow: 'hidden',
position: 'absolute',
right: 0,
top: 0,
}}
/>
<div
style={{
position: 'absolute',
@@ -36,7 +41,7 @@ export function TestimonialsShape({ fillColor }: TestimonialsShapeProps) {
bottom: 0,
left: 0,
right: 0,
backgroundColor: fillColor,
backgroundColor: bodyFillColor ?? fillColor,
borderBottomLeftRadius: 4,
borderBottomRightRadius: 4,
}}
@@ -0,0 +1,357 @@
'use client';
import { Body, Eyebrow, Heading, IconButton } from '@/design-system/components';
import type { EyebrowType } from '@/design-system/components/Eyebrow/types/Eyebrow';
import { ArrowLeftIcon, ArrowRightIcon } from '@/icons';
import type { TestimonialCardType } from '@/sections/Testimonials/types/TestimonialCard';
import { theme } from '@/theme';
import { css } from '@linaria/core';
import { styled } from '@linaria/react';
import NextImage from 'next/image';
import { type ReactNode, useState } from 'react';
import { Separator } from '../Separator/Separator';
const DATE_FORMATTER = new Intl.DateTimeFormat('en-US', {
day: 'numeric',
month: 'long',
year: 'numeric',
});
const nameTextClassName = css`
color: ${theme.colors.secondary.text[100]};
`;
const handleTextClassName = css`
color: ${theme.colors.secondary.text[100]};
`;
const dateTextClassName = css`
color: ${theme.colors.secondary.text[80]};
`;
const quoteHeadingClassName = css`
color: ${theme.colors.secondary.text[100]};
position: relative;
z-index: 1;
`;
const StyledCarousel = styled.div`
display: grid;
grid-template-columns: minmax(0, 1fr);
position: relative;
row-gap: ${theme.spacing(8)};
z-index: 0;
@media (min-width: ${theme.breakpoints.md}px) {
align-items: stretch;
column-gap: ${theme.spacing(15)};
grid-template-columns: minmax(0, 328px) auto minmax(0, 1fr);
row-gap: 0;
}
`;
const LeftColumn = styled.div`
display: grid;
row-gap: ${theme.spacing(6)};
@media (min-width: ${theme.breakpoints.md}px) {
align-content: space-between;
min-height: 580px;
row-gap: ${theme.spacing(12)};
}
`;
const AuthorCard = styled.div`
display: grid;
justify-items: start;
row-gap: ${theme.spacing(6)};
`;
const PortraitFrame = styled.div`
aspect-ratio: 1;
border-radius: ${theme.radius(2)};
max-width: 328px;
overflow: hidden;
position: relative;
width: 100%;
`;
const AuthorMeta = styled.div`
display: grid;
max-width: 253px;
row-gap: ${theme.spacing(2)};
`;
const NameHandleRow = styled.div`
align-items: center;
column-gap: ${theme.spacing(2)};
display: flex;
flex-wrap: wrap;
`;
const HandleText = styled.div`
border-left: 1px solid ${theme.colors.secondary.border[100]};
line-height: 0;
padding-left: ${theme.spacing(2)};
`;
const CounterText = styled.p`
color: ${theme.colors.secondary.text[100]};
font-family: ${theme.font.family.sans};
font-size: ${theme.font.size(10)};
font-weight: ${theme.font.weight.light};
letter-spacing: -0.04em;
line-height: ${theme.lineHeight(11.5)};
margin: 0;
text-align: start;
@media (min-width: ${theme.breakpoints.md}px) {
font-size: ${theme.font.size(12)};
line-height: ${theme.lineHeight(14)};
text-align: center;
}
`;
const RightColumn = styled.div`
display: grid;
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto auto auto;
min-width: 0;
position: relative;
row-gap: ${theme.spacing(8)};
@media (min-width: ${theme.breakpoints.md}px) {
grid-template-rows: auto minmax(392px, 1fr) auto;
min-height: 642px;
row-gap: ${theme.spacing(14)};
}
`;
const QuoteArea = styled.div`
min-width: 0;
position: relative;
@media (min-width: ${theme.breakpoints.md}px) {
min-height: 392px;
}
`;
const QuoteStack = styled.div`
display: grid;
min-width: 0;
`;
const QuoteWrapper = styled.div`
grid-area: 1 / 1;
max-width: 900px;
opacity: 0;
pointer-events: none;
transform: translateY(8px);
transition:
opacity 0.5s cubic-bezier(0.16, 1, 0.3, 1),
transform 0.5s cubic-bezier(0.16, 1, 0.3, 1);
visibility: hidden;
&[data-active='true'] {
opacity: 1;
pointer-events: auto;
transform: translateY(0);
visibility: visible;
}
`;
const QuoteDecoration = styled.div`
display: none;
@media (min-width: ${theme.breakpoints.md}px) {
display: block;
height: 544px;
overflow: hidden;
pointer-events: none;
position: absolute;
right: 48px;
top: 56px;
width: 646px;
z-index: 0;
}
`;
const QuoteDecorationVisual = styled.div`
@media (min-width: ${theme.breakpoints.md}px) {
position: absolute;
right: 36px;
top: -112px;
transform: scale(1.9);
transform-origin: top right;
}
`;
const FooterRow = styled.div`
position: relative;
z-index: 1;
`;
const NavGroup = styled.div`
column-gap: ${theme.spacing(2)};
display: grid;
grid-auto-flow: column;
justify-content: start;
`;
type PartnerCarouselProps = {
children?: ReactNode;
eyebrow: EyebrowType;
testimonials: TestimonialCardType[];
};
export function PartnerCarousel({
children,
eyebrow,
testimonials,
}: PartnerCarouselProps) {
const [index, setIndex] = useState(0);
const total = testimonials.length;
if (total === 0) return null;
const hasPrevious = index > 0;
const hasNext = index < total - 1;
const current = testimonials[index];
const goToPrevious = () => {
if (hasPrevious) setIndex(index - 1);
};
const goToNext = () => {
if (hasNext) setIndex(index + 1);
};
return (
<StyledCarousel
aria-label="Partner testimonials"
aria-roledescription="carousel"
role="region"
>
<LeftColumn>
<AuthorCard>
<PortraitFrame>
<NextImage
alt={current.author.avatar.alt || ''}
fill
priority
sizes="(min-width: 921px) 328px, 100vw"
src={current.author.avatar.src}
style={{
filter: 'grayscale(1) contrast(1.1)',
objectFit: 'cover',
}}
/>
</PortraitFrame>
<AuthorMeta>
<NameHandleRow>
<Body
as="span"
body={current.author.name}
className={nameTextClassName}
size="sm"
weight="medium"
/>
<HandleText>
<Body
as="span"
body={current.author.handle}
className={handleTextClassName}
size="sm"
/>
</HandleText>
</NameHandleRow>
<Body
as="p"
body={{ text: DATE_FORMATTER.format(current.author.date) }}
className={dateTextClassName}
size="xs"
/>
</AuthorMeta>
</AuthorCard>
<CounterText aria-live="polite">
{index + 1}/{total}
</CounterText>
</LeftColumn>
<Separator colorScheme="secondary" />
<RightColumn>
<Eyebrow colorScheme="secondary" heading={eyebrow.heading} />
<QuoteArea>
<QuoteStack>
{testimonials.map((testimonial, testimonialIndex) => (
<QuoteWrapper
key={testimonialIndex}
data-active={testimonialIndex === index}
>
<Heading
as="h2"
className={quoteHeadingClassName}
segments={testimonial.heading}
size="md"
weight="light"
/>
</QuoteWrapper>
))}
</QuoteStack>
{children ? (
<QuoteDecoration>
<QuoteDecorationVisual>{children}</QuoteDecorationVisual>
</QuoteDecoration>
) : null}
</QuoteArea>
<FooterRow>
<NavGroup>
<IconButton
ariaLabel="Previous testimonial"
borderColor={
hasPrevious
? theme.colors.secondary.border[20]
: theme.colors.secondary.border[10]
}
icon={ArrowLeftIcon}
iconFillColor="transparent"
iconSize={14}
iconStrokeColor={
hasPrevious
? theme.colors.secondary.text[80]
: theme.colors.secondary.text[20]
}
size={48}
onClick={goToPrevious}
/>
<IconButton
ariaLabel="Next testimonial"
borderColor={
hasNext
? theme.colors.secondary.border[20]
: theme.colors.secondary.border[10]
}
icon={ArrowRightIcon}
iconFillColor="transparent"
iconSize={14}
iconStrokeColor={
hasNext
? theme.colors.secondary.text[80]
: theme.colors.secondary.text[20]
}
size={48}
onClick={goToNext}
/>
</NavGroup>
</FooterRow>
</RightColumn>
</StyledCarousel>
);
}
@@ -29,6 +29,7 @@ type RootProps = {
backgroundColor: string;
children: ReactNode;
color: string;
shapeBodyFillColor?: string;
shapeFillColor?: string;
};
@@ -36,11 +37,17 @@ export function Root({
backgroundColor,
children,
color,
shapeBodyFillColor,
shapeFillColor,
}: RootProps) {
return (
<StyledSection style={{ backgroundColor, color }}>
{shapeFillColor && <TestimonialsShape fillColor={shapeFillColor} />}
{shapeFillColor && (
<TestimonialsShape
bodyFillColor={shapeBodyFillColor}
fillColor={shapeFillColor}
/>
)}
<StyledContainer>{children}</StyledContainer>
</StyledSection>
);
@@ -18,8 +18,8 @@ const StyledSeparator = styled.div`
}
`;
const SeparatorLine = styled.div`
background-color: ${theme.colors.primary.border[20]};
const SeparatorLine = styled.div<{ color: string }>`
background-color: ${(props) => props.color};
height: 1px;
min-width: 0;
width: 100%;
@@ -47,13 +47,22 @@ const SeparatorIcon = styled.div`
}
`;
export function Separator() {
type SeparatorProps = {
colorScheme?: 'primary' | 'secondary';
};
export function Separator({ colorScheme = 'primary' }: SeparatorProps) {
const lineColor =
colorScheme === 'secondary'
? theme.colors.secondary.border[20]
: theme.colors.primary.border[20];
return (
<StyledSeparator>
<SeparatorIcon>
<PlusIcon size={12} strokeColor={theme.colors.highlight[100]} />
</SeparatorIcon>
<SeparatorLine />
<SeparatorLine color={lineColor} />
<SeparatorIcon>
<PlusIcon size={12} strokeColor={theme.colors.highlight[100]} />
</SeparatorIcon>
@@ -1,12 +1,14 @@
import { Hourglass } from '@/illustrations/Testimonials/Hourglass';
import { Partner as PartnerVisual } from '@/illustrations/Testimonials/Partner';
import { Carousel } from './Carousel/Carousel';
import { PartnerCarousel } from './PartnerCarousel/PartnerCarousel';
import { Root } from './Root/Root';
import { Separator } from './Separator/Separator';
export const Testimonials = {
Carousel,
HomeVisual: Hourglass,
PartnerCarousel,
PartnerVisual,
Root,
Separator,