Update home card visuals and partner marketing assets (#19711)

## Summary
- replace static home card imagery with code-driven visuals for the
familiar interface, fast path, and live data cards
- refine the live data card interaction details, including hover states,
animated cursors, filter chips, table styling, and inline tag editing
- add shared company logos, people avatars, and updated partner
testimonial assets to support the new marketing visuals
- refresh related home, partner, case study, signoff, testimonials, and
design-system content/components to align with the updated marketing
presentation

## Testing
- `npx tsc -p tsconfig.json --noEmit` in `packages/twenty-website-new`

Co-authored-by: Abdullah <125115953+mabdullahabaid@users.noreply.github.com>
This commit is contained in:
Thomas des Francs
2026-04-15 08:35:46 +02:00
committed by GitHub
parent a9ea1c6eed
commit 3e48be4c31
58 changed files with 4321 additions and 168 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 732 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1003 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

@@ -3,17 +3,17 @@ import type { HelpedDataType } from '@/sections/Helped/types/HelpedData';
export const HELPED_DATA: HelpedDataType = {
eyebrow: {
heading: {
text: 'Twenty helped them',
text: 'In production.',
fontFamily: 'sans',
},
},
heading: [
{
text: 'Make your GTM team happy with a',
text: 'Dev teams power\ncompany-wide\n',
fontFamily: 'serif',
},
{
text: " CRM they'll love",
text: 'change with Twenty',
fontFamily: 'sans',
},
],
@@ -5,6 +5,7 @@ import type {
HeroKanbanPageDefinition,
HeroTablePageDefinition,
} from '@/sections/Hero/types';
import { SHARED_PEOPLE_AVATAR_URLS } from '@/lib/shared-asset-paths';
const PEOPLE_AVATAR_URLS = {
craigFederighi:
@@ -12,7 +13,7 @@ const PEOPLE_AVATAR_URLS = {
eddyCue: 'https://twentyhq.github.io/placeholder-images/people/image-18.png',
jeffWilliams:
'https://twentyhq.github.io/placeholder-images/people/image-22.png',
katherineAdams: '/images/home/hero/avatars/katherine-adams.jpg',
katherineAdams: SHARED_PEOPLE_AVATAR_URLS.katherineAdams,
philSchiller:
'https://twentyhq.github.io/placeholder-images/people/image-14.png',
timCook: 'https://twentyhq.github.io/placeholder-images/people/image-27.png',
@@ -8,7 +8,8 @@ export const THREE_CARDS_FEATURE_DATA: ThreeCardsFeatureCardsDataType = {
},
},
heading: [
{ text: 'Make your GTM team happy with ', fontFamily: 'serif' },
{ text: 'Make your GTM team happy', fontFamily: 'serif' },
{ text: 'with ', fontFamily: 'serif', lineBreakBefore: true },
{ text: "a CRM they'll love", fontFamily: 'sans' },
],
featureCards: [
@@ -17,30 +18,24 @@ export const THREE_CARDS_FEATURE_DATA: ThreeCardsFeatureCardsDataType = {
body: {
text: "Twenty makes it simple. It's clean, intuitive, and built to feel like Notion.",
},
image: {
src: '/images/home/three-cards-feature/familiar-interface.webp',
alt: 'Familiar interface',
},
},
{
heading: { text: 'Fast path to action', fontFamily: 'sans' },
body: {
text: 'Smart patterns, shortcuts, and layouts make everyday tasks faster and easier to execute.',
},
image: {
src: '/images/home/three-cards-feature/fast-path.webp',
alt: 'Fast path',
},
icon: 'users-group',
illustration: 'familiar-interface',
},
{
heading: { text: 'Live data and AI Built', fontFamily: 'sans' },
body: {
text: 'Everything updates in real time, with AI chat always ready to help you move faster.',
},
image: {
src: '/images/home/three-cards-feature/live-data.webp',
alt: 'Live data',
icon: 'live-data',
illustration: 'live-data',
},
{
heading: { text: 'Fast path to action', fontFamily: 'sans' },
body: {
text: 'Smart patterns, shortcuts, and layouts make everyday tasks faster and easier to execute.',
},
icon: 'fast-path',
illustration: 'fast-path',
},
],
};
@@ -7,7 +7,7 @@ import { Menu } from '@/sections/Menu/components';
import { theme } from '@/theme';
import type { Metadata } from 'next';
const PLACEHOLDER_HERO = '/images/home/hero/avatars/katherine-adams.jpg';
const PLACEHOLDER_HERO = '/images/shared/people/avatars/katherine-adams.jpg';
const CASE_STUDY: CaseStudyData = {
meta: {
@@ -7,7 +7,7 @@ import { Menu } from '@/sections/Menu/components';
import { theme } from '@/theme';
import type { Metadata } from 'next';
const PLACEHOLDER_HERO = '/images/home/hero/avatars/katherine-adams.jpg';
const PLACEHOLDER_HERO = '/images/shared/people/avatars/katherine-adams.jpg';
const CASE_STUDY: CaseStudyData = {
meta: {
@@ -7,7 +7,7 @@ import { Menu } from '@/sections/Menu/components';
import { theme } from '@/theme';
import type { Metadata } from 'next';
const PLACEHOLDER_HERO = '/images/home/hero/avatars/katherine-adams.jpg';
const PLACEHOLDER_HERO = '/images/shared/people/avatars/katherine-adams.jpg';
const CASE_STUDY: CaseStudyData = {
meta: {
@@ -7,7 +7,7 @@ import { Menu } from '@/sections/Menu/components';
import { theme } from '@/theme';
import type { Metadata } from 'next';
const PLACEHOLDER_HERO = '/images/home/hero/avatars/katherine-adams.jpg';
const PLACEHOLDER_HERO = '/images/shared/people/avatars/katherine-adams.jpg';
const CASE_STUDY: CaseStudyData = {
meta: {
@@ -7,7 +7,7 @@ import { Menu } from '@/sections/Menu/components';
import { theme } from '@/theme';
import type { Metadata } from 'next';
const PLACEHOLDER_HERO = '/images/home/hero/avatars/katherine-adams.jpg';
const PLACEHOLDER_HERO = '/images/shared/people/avatars/katherine-adams.jpg';
const CASE_STUDY: CaseStudyData = {
meta: {
@@ -36,7 +36,7 @@ const SIGNOFF_BODY = {
text: 'Join the teams that chose to own their CRM. Start building with Twenty today.',
};
const PLACEHOLDER_HERO = '/images/home/hero/avatars/katherine-adams.jpg';
const PLACEHOLDER_HERO = '/images/shared/people/avatars/katherine-adams.jpg';
const CASE_STUDY_CATALOG_ENTRIES: CaseStudyCatalogEntry[] = [
{
@@ -7,7 +7,7 @@ import { Menu } from '@/sections/Menu/components';
import { theme } from '@/theme';
import type { Metadata } from 'next';
const PLACEHOLDER_HERO = '/images/home/hero/avatars/katherine-adams.jpg';
const PLACEHOLDER_HERO = '/images/shared/people/avatars/katherine-adams.jpg';
const CASE_STUDY: CaseStudyData = {
meta: {
@@ -364,6 +364,38 @@ function createRenderTarget(width: number, height: number) {
});
}
function syncImageElementTexture(
resources: SceneResources,
imageElement: HTMLImageElement | null,
) {
if (resources.imageTexture) {
resources.imageTexture.dispose();
resources.imageTexture = null;
}
if (!imageElement) {
resources.imageMaterial.uniforms.tImage.value = null;
resources.imageMaterial.uniforms.imageSize.value.set(1, 1);
return;
}
const texture = new THREE.Texture(imageElement);
texture.wrapS = THREE.ClampToEdgeWrapping;
texture.wrapT = THREE.ClampToEdgeWrapping;
texture.generateMipmaps = false;
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.needsUpdate = true;
texture.colorSpace = THREE.SRGBColorSpace;
resources.imageTexture = texture;
resources.imageMaterial.uniforms.tImage.value = texture;
resources.imageMaterial.uniforms.imageSize.value.set(
imageElement.naturalWidth,
imageElement.naturalHeight,
);
}
function createInteractionState(
initialPose?: Partial<HalftoneExportPose>,
): InteractionState {
@@ -584,6 +616,7 @@ export function HalftoneCanvas({
const poseChangeReference = useRef(onPoseChange);
const previewDistanceReference = useRef(previewDistance);
const geometryReference = useRef(geometry);
const imageElementReference = useRef(imageElement);
const snapshotReference = useRef(snapshotRef);
useEffect(() => {
@@ -663,38 +696,15 @@ export function HalftoneCanvas({
}, [snapshotRef]);
useEffect(() => {
imageElementReference.current = imageElement;
const resources = resourcesReference.current;
if (!resources) {
return;
}
if (imageElement) {
if (resources.imageTexture) {
resources.imageTexture.dispose();
}
const texture = new THREE.Texture(imageElement);
texture.wrapS = THREE.ClampToEdgeWrapping;
texture.wrapT = THREE.ClampToEdgeWrapping;
texture.generateMipmaps = false;
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.needsUpdate = true;
texture.colorSpace = THREE.SRGBColorSpace;
resources.imageTexture = texture;
resources.imageMaterial.uniforms.tImage.value = texture;
resources.imageMaterial.uniforms.imageSize.value.set(
imageElement.naturalWidth,
imageElement.naturalHeight,
);
} else {
if (resources.imageTexture) {
resources.imageTexture.dispose();
resources.imageTexture = null;
resources.imageMaterial.uniforms.tImage.value = null;
}
}
syncImageElementTexture(resources, imageElement);
}, [imageElement]);
useEffect(() => {
@@ -1037,6 +1047,7 @@ export function HalftoneCanvas({
}
syncResources(resources, settingsReference.current);
syncImageElementTexture(resources, imageElementReference.current);
const captureSnapshot: HalftoneSnapshotFn = async (
snapshotWidth: number,
@@ -2,10 +2,10 @@ import type { SignoffDataType } from '@/sections/Signoff/types';
export const SIGNOFF_DATA: SignoffDataType = {
heading: [
{ text: 'Ready to grow', fontFamily: 'serif' },
{ text: ' with Twenty?', fontFamily: 'sans' },
{ text: 'Ready to grow\n', fontFamily: 'serif' },
{ text: 'with Twenty?', fontFamily: 'sans' },
],
body: {
text: 'Join our partner ecosystem and help businesses take control of their CRM.',
text: 'Join our partner ecosystem and help businesses\ntake control of their CRM.',
},
};
@@ -13,6 +13,10 @@ export const TESTIMONIALS_DATA: TestimonialsDataType = {
author: {
name: { text: 'Benjamin Reynolds' },
designation: { text: 'Principal and Founder, Alternative Partners' },
avatar: {
src: '/images/partner/testimonials/benjamin-reynolds.webp',
alt: 'Portrait of Benjamin Reynolds',
},
},
},
{
@@ -23,6 +27,10 @@ export const TESTIMONIALS_DATA: TestimonialsDataType = {
author: {
name: { text: 'Bertrams' },
designation: { text: 'Founder, Wintactix' },
avatar: {
src: '/images/partner/testimonials/bertrams.jpeg',
alt: 'Portrait of Bertrams',
},
},
},
{
@@ -33,6 +41,10 @@ export const TESTIMONIALS_DATA: TestimonialsDataType = {
author: {
name: { text: 'Mike Babiy' },
designation: { text: 'Founder, Nine Dots Ventures' },
avatar: {
src: '/images/partner/testimonials/mike-babiy.png',
alt: 'Photo featuring Mike Babiy',
},
},
},
],
@@ -126,9 +126,10 @@ export default async function PartnerPage() {
<Signoff.Root
backgroundColor={theme.colors.primary.background[100]}
color={theme.colors.primary.text[100]}
page={Pages.Partner}
>
<Signoff.Heading segments={SIGNOFF_DATA.heading} />
<Signoff.Body body={SIGNOFF_DATA.body} />
<Signoff.Heading page={Pages.Partner} segments={SIGNOFF_DATA.heading} />
<Signoff.Body body={SIGNOFF_DATA.body} page={Pages.Partner} />
<Signoff.Cta>
<PartnerSignoffCtas />
</Signoff.Cta>
@@ -3,8 +3,9 @@ import { css } from '@linaria/core';
import { BodyType } from './types/Body';
const bodyClassName = css`
color: inherit;
margin: 0;
letter-spacing: -0.01em;
letter-spacing: 0;
&[data-family='sans'] {
font-family: ${theme.font.family.sans};
@@ -37,6 +38,7 @@ const bodyClassName = css`
}
&[data-size='sm'] {
color: ${theme.colors.primary.text[60]};
font-size: ${theme.font.size(4)};
line-height: ${theme.lineHeight(5.5)};
}
@@ -0,0 +1,140 @@
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import type { CSSProperties } from 'react';
const Root = styled.div`
display: none;
@media (min-width: ${theme.breakpoints.md}px) {
display: block;
inset: 0;
pointer-events: none;
position: absolute;
z-index: var(--guide-z-index, 1);
}
`;
const HorizontalLineLeft = styled.div`
@media (min-width: ${theme.breakpoints.md}px) {
background-color: var(--guide-line-color);
height: var(--guide-stroke);
left: 0;
position: absolute;
top: var(--guide-cross-y);
width: calc(var(--guide-cross-x) - var(--guide-gap));
}
`;
const HorizontalLineRight = styled.div`
@media (min-width: ${theme.breakpoints.md}px) {
background-color: var(--guide-line-color);
height: var(--guide-stroke);
position: absolute;
right: 0;
top: var(--guide-cross-y);
width: calc(100% - var(--guide-cross-x) - var(--guide-gap));
}
`;
const VerticalLineTop = styled.div`
@media (min-width: ${theme.breakpoints.md}px) {
background-color: var(--guide-line-color);
height: calc(var(--guide-cross-y) - var(--guide-gap));
left: var(--guide-cross-x);
position: absolute;
top: 0;
width: var(--guide-stroke);
}
`;
const VerticalLineBottom = styled.div`
@media (min-width: ${theme.breakpoints.md}px) {
background-color: var(--guide-line-color);
bottom: 0;
height: calc(100% - var(--guide-cross-y) - var(--guide-gap));
left: var(--guide-cross-x);
position: absolute;
width: var(--guide-stroke);
}
`;
const Plus = styled.div`
@media (min-width: ${theme.breakpoints.md}px) {
height: var(--guide-plus-size);
left: calc(var(--guide-cross-x) - var(--guide-plus-offset));
position: absolute;
top: calc(var(--guide-cross-y) - var(--guide-plus-offset));
width: var(--guide-plus-size);
&::before,
&::after {
background-color: var(--guide-plus-color);
content: '';
position: absolute;
}
&::before {
height: var(--guide-plus-size);
left: 50%;
top: 0;
transform: translateX(-50%);
width: var(--guide-stroke);
}
&::after {
height: var(--guide-stroke);
left: 0;
top: 50%;
transform: translateY(-50%);
width: var(--guide-plus-size);
}
}
`;
type GuideCrosshairProps = {
className?: string;
crossX: string;
crossY: string;
gap?: string;
lineColor?: string;
plusColor?: string;
plusOffset?: string;
plusSize?: string;
strokeWidth?: string;
zIndex?: number;
};
export function GuideCrosshair({
className,
crossX,
crossY,
gap = '18px',
lineColor = theme.colors.primary.border[10],
plusColor = theme.colors.highlight[100],
plusOffset = '6px',
plusSize = '12px',
strokeWidth = '1px',
zIndex = 1,
}: GuideCrosshairProps) {
const style = {
'--guide-cross-x': crossX,
'--guide-cross-y': crossY,
'--guide-gap': gap,
'--guide-line-color': lineColor,
'--guide-plus-color': plusColor,
'--guide-plus-offset': plusOffset,
'--guide-plus-size': plusSize,
'--guide-stroke': strokeWidth,
'--guide-z-index': String(zIndex),
} as CSSProperties;
return (
<Root aria-hidden className={className} style={style}>
<HorizontalLineLeft />
<HorizontalLineRight />
<VerticalLineTop />
<VerticalLineBottom />
<Plus />
</Root>
);
}
@@ -128,17 +128,22 @@ export function Heading({
return (
<Tag className={rootClassName} data-weight={weight} data-size={size}>
{Array.isArray(segments) ? (
segments.map((segment, index) => (
<Fragment key={index}>
{segment.newLine ? <br /> : null}
<StyledSpan
data-family={segment.fontFamily}
data-weight={segment.fontWeight}
>
{segment.text}
</StyledSpan>
</Fragment>
))
segments.map((segment, index) => {
const lineBreakBefore =
segment.newLine === true || segment.lineBreakBefore === true;
return (
<Fragment key={index}>
{lineBreakBefore ? <br /> : null}
<StyledSpan
data-family={segment.fontFamily}
data-weight={segment.fontWeight}
>
{segment.text}
</StyledSpan>
</Fragment>
);
})
) : (
<StyledSpan
data-family={segments.fontFamily}
@@ -1,6 +1,7 @@
export type HeadingType = {
fontFamily: 'sans' | 'serif' | 'mono';
text: string;
fontWeight?: 'light' | 'regular' | 'medium';
newLine?: boolean;
text: string;
lineBreakBefore?: boolean;
};
@@ -3,6 +3,7 @@ export { LinkButton } from './Button/LinkButton';
export { SubmitButton } from './Button/SubmitButton';
export { Container } from './Container/Container';
export { Eyebrow } from './Eyebrow/Eyebrow';
export { GuideCrosshair } from './GuideCrosshair/GuideCrosshair';
export { Heading } from './Heading/Heading';
export { IconButton } from './IconButton/IconButton';
export { Image } from './Image/Image';
@@ -0,0 +1,26 @@
interface FastPathIconProps {
size?: number;
strokeColor: string;
}
export function FastPathIcon({
size = 14.667,
strokeColor,
}: FastPathIconProps) {
return (
<svg
aria-hidden="true"
width={size}
height={size * (14.7651 / 14.7908)}
viewBox="0 0 14.7908 14.7651"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12.2843 7.75532C12.2843 10.6142 9.94478 12.9318 7.05882 12.9318C4.17287 12.9318 1.83333 10.6142 1.83333 7.75532C1.83333 4.89643 4.17287 2.57885 7.05882 2.57885M9.67157 7.75532C9.67157 9.18476 8.5018 10.3436 7.05882 10.3436C5.61584 10.3436 4.44608 9.18476 4.44608 7.75532C4.44608 6.32587 5.61584 5.16708 7.05882 5.16708M9.36819 5.36328L7.05882 7.65098M12.8333 3.76195L10.7549 5.82087L9.36929 5.36334L8.90742 3.99072L10.9858 1.93179L11.2168 3.53318L12.8333 3.76195Z"
stroke={strokeColor}
strokeWidth="1.83333"
/>
</svg>
);
}
@@ -0,0 +1,36 @@
interface LiveDataIconProps {
size?: number;
strokeColor: string;
}
export function LiveDataIcon({
size = 14.667,
strokeColor,
}: LiveDataIconProps) {
return (
<svg
aria-hidden="true"
width={size}
height={size * (11.1079 / 14.0366)}
viewBox="0 0 14.0366 11.1079"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.31766 4.90883C8.31766 5.77895 7.61229 6.48433 6.74217 6.48433C5.87204 6.48433 5.16667 5.77895 5.16667 4.90883C5.16667 4.03871 5.87204 3.33333 6.74217 3.33333C7.61229 3.33333 8.31766 4.03871 8.31766 4.90883Z"
stroke={strokeColor}
strokeWidth="1.83333"
/>
<path
d="M10.5028 2.16583C9.51489 1.45043 8.31376 0.916667 7.01835 0.916667C3.64848 0.916667 0.916667 4.52877 0.916667 5.55394C0.916667 6.20643 2.02328 7.90684 3.6967 9.05693"
stroke={strokeColor}
strokeWidth="1.83333"
/>
<path
d="M12.1895 3.77217C12.779 4.50673 13.12 5.19175 13.12 5.55392C13.12 6.5791 10.3881 10.1912 7.01828 10.1912C6.59043 10.1912 6.17286 10.133 5.76987 10.0292"
stroke={strokeColor}
strokeWidth="1.83333"
/>
</svg>
);
}
@@ -0,0 +1,25 @@
const RATING_STAR_PATH =
'M4.76186 0.60019C5.05804 -0.200134 6.19001 -0.200133 6.48619 0.600191L7.46388 3.24205C7.557 3.49365 7.75537 3.69203 8.00698 3.78514L10.6488 4.76284C11.4492 5.05902 11.4492 6.19098 10.6488 6.48717L8.00698 7.46486C7.75537 7.55797 7.557 7.75635 7.46388 8.00796L6.48619 10.6498C6.19001 11.4501 5.05804 11.4501 4.76186 10.6498L3.78417 8.00796C3.69105 7.75635 3.49267 7.55797 3.24107 7.46486L0.599213 6.48717C-0.201111 6.19098 -0.20111 5.05902 0.599214 4.76284L3.24107 3.78514C3.49267 3.69203 3.69105 3.49365 3.78417 3.24205L4.76186 0.60019Z';
interface RatingStarIconProps {
fillColor: string;
size?: number;
}
export function RatingStarIcon({
fillColor,
size = 12,
}: RatingStarIconProps) {
return (
<svg
aria-hidden="true"
width={size}
height={size}
viewBox="0 0 12 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d={RATING_STAR_PATH} fill={fillColor} />
</svg>
);
}
@@ -12,9 +12,12 @@ export { ArrowLeftIcon } from './functional/ArrowLeft';
export { ArrowRightIcon } from './functional/ArrowRight';
export { ArrowRightUpIcon } from './functional/ArrowRightUp';
export { CloseIcon } from './functional/Close';
export { FastPathIcon } from './functional/FastPath';
export { LiveDataIcon } from './functional/LiveData';
export { MenuIcon } from './functional/Menu';
export { MinusIcon } from './functional/Minus';
export { PlusIcon } from './functional/Plus';
export { RatingStarIcon } from './functional/RatingStar';
export { RectangleFillIcon } from './functional/RectangleFill';
export { RectangleOutlineIcon } from './functional/RectangleOutline';
export {
@@ -0,0 +1,199 @@
'use client';
import { HalftoneCanvas } from '@/app/halftone/_components/HalftoneCanvas';
import type { HalftoneStudioSettings } from '@/app/halftone/_lib/state';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import { useEffect, useRef, useState } from 'react';
import * as THREE from 'three';
const PARTNER_EFFECT_PREVIEW_DISTANCE = 4;
const PARTNER_EFFECT_SETTINGS: HalftoneStudioSettings = {
sourceMode: 'image',
shapeKey: 'torusKnot',
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: 8,
power: -0.07,
width: 0.46,
imageContrast: 1,
dashColor: '#FFFFFF',
hoverDashColor: '#FFFFFF',
},
background: {
transparent: true,
color: 'transparent',
},
animation: {
autoRotateEnabled: true,
breatheEnabled: false,
cameraParallaxEnabled: false,
followHoverEnabled: false,
followDragEnabled: false,
floatEnabled: false,
hoverHalftoneEnabled: true,
hoverLightEnabled: true,
dragFlowEnabled: false,
lightSweepEnabled: false,
rotateEnabled: false,
autoSpeed: 0.2,
autoWobble: 0.3,
breatheAmount: 0.04,
breatheSpeed: 0.8,
cameraParallaxAmount: 0.3,
cameraParallaxEase: 0.08,
driftAmount: 8,
hoverRange: 64,
hoverEase: 0.08,
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.5,
lightSweepRange: 28,
lightSweepSpeed: 0.7,
springDamping: 0.72,
springReturnEnabled: false,
springStrength: 0.18,
hoverHalftonePowerShift: 0.42,
hoverHalftoneRadius: 0.9,
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,
},
};
const noopFirstInteraction = () => {};
const noopPoseChange = () => {};
const EffectFrame = styled.div`
height: 100%;
position: relative;
width: 100%;
& canvas {
cursor: default !important;
}
`;
const EffectPlaceholder = styled.div`
align-items: center;
background-color: ${theme.colors.secondary.border[10]};
color: ${theme.colors.secondary.text[60]};
display: flex;
font-family: ${theme.font.family.sans};
font-size: ${theme.font.size(12)};
font-weight: ${theme.font.weight.medium};
height: 100%;
justify-content: center;
letter-spacing: -0.02em;
width: 100%;
`;
type PartnerEffectProps = {
alt: string;
fallback: string;
src: string;
};
export function PartnerEffect({ alt, fallback, src }: PartnerEffectProps) {
const [imageElement, setImageElement] = useState<HTMLImageElement | null>(
null,
);
const geometryReference = useRef<THREE.PlaneGeometry | null>(null);
if (geometryReference.current === null) {
geometryReference.current = new THREE.PlaneGeometry(1, 1);
}
useEffect(() => {
const geometry = geometryReference.current;
return () => {
geometry?.dispose();
geometryReference.current = null;
};
}, []);
useEffect(() => {
let cancelled = false;
setImageElement(null);
const image = new Image();
image.decoding = 'async';
image.onload = () => {
if (cancelled) {
return;
}
setImageElement(image);
};
image.onerror = () => {
if (cancelled) {
return;
}
console.error(`Failed to load testimonial portrait: ${src}`);
};
image.src = src;
return () => {
cancelled = true;
image.onload = null;
image.onerror = null;
image.src = '';
};
}, [src]);
return (
<EffectFrame aria-label={alt} role="img">
{imageElement && geometryReference.current ? (
<HalftoneCanvas
geometry={geometryReference.current}
imageElement={imageElement}
onFirstInteraction={noopFirstInteraction}
onPoseChange={noopPoseChange}
previewDistance={PARTNER_EFFECT_PREVIEW_DISTANCE}
settings={PARTNER_EFFECT_SETTINGS}
/>
) : (
<EffectPlaceholder aria-hidden={Boolean(alt)}>
{fallback}
</EffectPlaceholder>
)}
</EffectFrame>
);
}
@@ -0,0 +1,16 @@
export const SHARED_COMPANY_LOGO_URLS = {
airbnb: '/images/shared/companies/logos/airbnb.png',
figma: '/images/shared/companies/logos/figma.png',
github: '/images/shared/companies/logos/github.png',
notion: '/images/shared/companies/logos/notion.png',
sequoia: '/images/shared/companies/logos/sequoia.png',
slack: '/images/shared/companies/logos/slack.png',
} as const;
export const SHARED_PEOPLE_AVATAR_URLS = {
chrisWanstrath: '/images/shared/people/avatars/chris-wanstrath.jpg',
dylanField: '/images/shared/people/avatars/dylan-field.jpg',
eddyCue: '/images/shared/people/avatars/eddy-cue.jpg',
jeffWilliams: '/images/shared/people/avatars/jeff-williams.jpg',
katherineAdams: '/images/shared/people/avatars/katherine-adams.jpg',
} as const;
@@ -1,13 +1,27 @@
'use client';
import { Eyebrow, Heading } from '@/design-system/components';
import { Eyebrow, GuideCrosshair, Heading } from '@/design-system/components';
import { HelpedSceneScrollLayoutEffect } from '@/sections/Helped/effect-components/HelpedSceneScrollLayoutEffect';
import type { HelpedDataType } from '@/sections/Helped/types/HelpedData';
import { theme } from '@/theme';
import { css } from '@linaria/core';
import { styled } from '@linaria/react';
import { useRef } from 'react';
import { Card } from '../Card/Card';
const GUIDE_INTERSECTION_TOP = '176px';
const helpedHeadingClassName = css`
@media (min-width: ${theme.breakpoints.md}px) {
max-width: 760px;
white-space: pre-line;
}
[data-family='sans'] {
white-space: nowrap;
}
`;
const ScrollStage = styled.section`
height: 280vh;
position: relative;
@@ -39,6 +53,11 @@ const HeadlineBlock = styled.div`
z-index: 1;
`;
const EyebrowExitTarget = styled.div`
display: grid;
justify-items: center;
`;
const CardsLayer = styled.div`
inset: 0;
pointer-events: none;
@@ -65,7 +84,11 @@ export function Scene({ data }: SceneProps) {
const cardRefs = useRef<(HTMLDivElement | null)[]>([]);
return (
<ScrollStage aria-label="Customer stories" ref={sectionRef}>
<ScrollStage
aria-label="Customer stories"
id="homepage-cases"
ref={sectionRef}
>
<HelpedSceneScrollLayoutEffect
cardRefs={cardRefs}
cards={data.cards}
@@ -73,9 +96,18 @@ export function Scene({ data }: SceneProps) {
sectionRef={sectionRef}
/>
<StickyInner ref={innerRef}>
<GuideCrosshair crossX="50%" crossY={GUIDE_INTERSECTION_TOP} zIndex={0} />
<HeadlineBlock>
<Eyebrow colorScheme="primary" heading={data.eyebrow.heading} />
<Heading as="h2" segments={data.heading} size="xl" weight="light" />
<EyebrowExitTarget data-helped-exit-target>
<Eyebrow colorScheme="primary" heading={data.eyebrow.heading} />
</EyebrowExitTarget>
<Heading
as="h2"
className={helpedHeadingClassName}
segments={data.heading}
size="xl"
weight="light"
/>
</HeadlineBlock>
<CardsLayer>
{data.cards.map((card, index) => (
@@ -5,13 +5,23 @@ import { theme } from '@/theme';
const CARD_WIDTH_DESKTOP = 443;
const CARD_WIDTH_MOBILE = 360;
const PROGRESS_SCALE = 1.25;
const FADE_FRACTION = 0.15;
const PRE_STICKY_REVEAL_VIEWPORT_FRACTION = 0.35;
const CARD_TRAVEL_RANGE = 0.5;
const CARD_TRAVEL_START = 0.05;
const CARD_TRAVEL_STEP = 0.3;
const POST_STICKY_PARALLAX_DISTANCE = 0.7;
function clamp01(value: number) {
return Math.min(1, Math.max(0, value));
}
function easeOutQuad(value: number) {
const clampedValue = clamp01(value);
return 1 - (1 - clampedValue) * (1 - clampedValue);
}
// Right, left, center for cards 0, 1, 2+
function cardLeft(
index: number,
@@ -37,6 +47,32 @@ export type HelpedSceneLayoutRefs = {
sectionRef: RefObject<HTMLElement | null>;
};
function getProgressScale(
cards: readonly HeadingCardType[],
cardRefs: RefObject<(HTMLDivElement | null)[]>,
inner: HTMLDivElement,
innerHeight: number,
) {
const exitTargetNode = inner.querySelector('[data-helped-exit-target]');
const lastCardNode = cardRefs.current[cards.length - 1];
if (!(exitTargetNode instanceof HTMLElement) || !lastCardNode) {
return 1;
}
const exitTargetTop =
exitTargetNode.getBoundingClientRect().top -
inner.getBoundingClientRect().top;
const lastCardHeight = lastCardNode.offsetHeight;
const lastCardStart =
CARD_TRAVEL_START + (cards.length - 1) * CARD_TRAVEL_STEP;
const requiredTravel = clamp01(
(innerHeight * 1.1 + lastCardHeight - exitTargetTop) / (innerHeight * 1.4),
);
return lastCardStart + requiredTravel * CARD_TRAVEL_RANGE;
}
export function applyHelpedSceneLayout(
refs: HelpedSceneLayoutRefs,
cards: readonly HeadingCardType[],
@@ -53,11 +89,11 @@ export function applyHelpedSceneLayout(
const isDesktop = window.matchMedia(
`(min-width: ${theme.breakpoints.md}px)`,
).matches;
const sectionRect = section.getBoundingClientRect();
const scrollRange = Math.max(1, section.offsetHeight - window.innerHeight);
const progress =
clamp01(-section.getBoundingClientRect().top / scrollRange) * PROGRESS_SCALE;
const preStickyRevealOffset =
window.innerHeight * PRE_STICKY_REVEAL_VIEWPORT_FRACTION;
const innerWidth = inner.offsetWidth;
const innerHeight = inner.offsetHeight;
const cardWidth = Math.min(
@@ -74,6 +110,31 @@ export function applyHelpedSceneLayout(
node.style.width = `${cardWidth}px`;
node.style.zIndex = String(10 + index);
node.style.left = `${cardLeft(index, innerWidth, cardWidth, isDesktop)}px`;
});
const progressScale = getProgressScale(
cards,
refs.cardRefs,
inner,
innerHeight,
);
const progress =
clamp01(
(preStickyRevealOffset - sectionRect.top) /
(scrollRange + preStickyRevealOffset),
) * progressScale;
const postStickyParallaxOffset =
easeOutQuad(
(window.innerHeight - sectionRect.bottom) / window.innerHeight,
) *
innerHeight *
POST_STICKY_PARALLAX_DISTANCE;
cards.forEach((_, index) => {
const node = refs.cardRefs.current[index];
if (!node) {
return;
}
if (reducedMotion) {
node.style.opacity = '1';
@@ -81,11 +142,10 @@ export function applyHelpedSceneLayout(
return;
}
// Card 1: 0.050.55, Card 2: 0.350.85, Card 3: 0.651.15
const cardStart = 0.05 + index * 0.3;
const travel = clamp01((progress - cardStart) / 0.5);
const cardStart = CARD_TRAVEL_START + index * CARD_TRAVEL_STEP;
const travel = clamp01((progress - cardStart) / CARD_TRAVEL_RANGE);
node.style.top = `${innerHeight * (1.1 - travel * 1.4)}px`;
node.style.top = `${innerHeight * (1.1 - travel * 1.4) - postStickyParallaxOffset}px`;
node.style.opacity = String(
Math.min(
clamp01(travel / FADE_FRACTION),
@@ -7,15 +7,14 @@ import { theme } from '@/theme';
import { styled } from '@linaria/react';
const BodyContainer = styled.div`
color: ${theme.colors.primary.text[60]};
margin-top: calc(${theme.spacing(2)} - ${theme.spacing(6)});
max-width: 360px;
width: 100%;
&[data-page='whyTwenty'] {
color: ${theme.colors.secondary.text[60]};
}
margin-top: calc(${theme.spacing(2)} - ${theme.spacing(6)});
max-width: 360px;
width: 100%;
@media (min-width: ${theme.breakpoints.md}px) {
&[data-page='home'] {
max-width: 591px;
@@ -1,5 +1,6 @@
'use client';
import { RatingStarIcon } from '@/icons';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import {
@@ -247,11 +248,12 @@ const StarsRow = styled.div`
padding: 0 4px;
`;
const StarGlyph = styled.span<{ $filled: boolean }>`
color: ${({ $filled }) => ($filled ? COLORS.textSecondary : '#d6d6d6')};
font-family: ${APP_FONT};
font-size: 13px;
line-height: 1;
const StarGlyph = styled.span`
align-items: center;
display: inline-flex;
height: 12px;
justify-content: center;
width: 12px;
`;
const AddCardButton = styled.div`
@@ -448,8 +450,10 @@ function RatingValue({ rating }: { rating: number }) {
return (
<StarsRow>
{Array.from({ length: 5 }, (_, index) => (
<StarGlyph key={index} $filled={index < rating}>
{index < rating ? '★' : '★'}
<StarGlyph key={index}>
<RatingStarIcon
fillColor={index < rating ? COLORS.textSecondary : '#d6d6d6'}
/>
</StarGlyph>
))}
</StarsRow>
@@ -33,10 +33,6 @@ const StyledPoint = styled.div`
}
`;
const StyledBody = styled(Body)`
color: ${theme.colors.primary.text[80]};
`;
export type PointsProps = { points: ProblemPointType[] };
export function Points({ points }: PointsProps) {
@@ -47,7 +43,7 @@ export function Points({ points }: PointsProps) {
<StyledDivider />
<StyledPoint>
<Heading segments={point.heading} weight="medium" size="xs" />
<StyledBody body={point.body} size="sm" />
<Body body={point.body} size="sm" />
</StyledPoint>
</React.Fragment>
))}
@@ -29,10 +29,6 @@ const CopyColumn = styled.div`
}
`;
const DescriptionBody = styled(Body)`
color: ${theme.colors.primary.text[80]};
`;
const RightColumn = styled.div`
max-width: 672px;
min-width: 0;
@@ -277,12 +273,7 @@ export function Flow({ backgroundColor, body, heading, pricing }: FlowProps) {
<Root backgroundColor={backgroundColor}>
<CopyColumn>
<Heading as="h2" segments={heading} size="lg" weight="light" />
<DescriptionBody
body={body}
family="sans"
size="md"
weight="regular"
/>
<Body body={body} family="sans" size="md" weight="regular" />
</CopyColumn>
<RightColumn ref={rightColumnRef}>
{isPricingWindowVisible ? (
@@ -2,6 +2,7 @@ import { styled } from '@linaria/react';
import { Body as BaseBody } from '@/design-system/components';
import type { BodyType } from '@/design-system/components/Body/types/Body';
import type { Pages } from '@/enums/pages';
import { theme } from '@/theme';
const Subline = styled.div`
@@ -10,15 +11,22 @@ const Subline = styled.div`
max-width: 452px;
min-width: 0;
width: 100%;
@media (min-width: ${theme.breakpoints.md}px) {
&[data-page='partner'] {
white-space: pre-line;
}
}
`;
type BodyProps = {
body: BodyType;
page?: Pages;
};
export function Body({ body }: BodyProps) {
export function Body({ body, page }: BodyProps) {
return (
<Subline>
<Subline data-page={page}>
<BaseBody as="p" body={body} size="sm" weight="regular" />
</Subline>
);
@@ -6,8 +6,9 @@ import { theme } from '@/theme';
const Actions = styled.div`
display: flex;
flex-wrap: wrap;
gap: ${theme.spacing(1)};
column-gap: ${theme.spacing(2)};
justify-content: center;
row-gap: ${theme.spacing(1)};
`;
type CtaProps = {
@@ -2,6 +2,7 @@ import { styled } from '@linaria/react';
import { Heading as BaseHeading } from '@/design-system/components';
import type { HeadingType } from '@/design-system/components/Heading/types/Heading';
import type { Pages } from '@/enums/pages';
import { theme } from '@/theme';
const HeadingWrap = styled.div`
@@ -11,15 +12,22 @@ const HeadingWrap = styled.div`
max-width: 921px;
min-width: 0;
width: 100%;
@media (min-width: ${theme.breakpoints.md}px) {
&[data-page='partner'] {
white-space: pre-line;
}
}
`;
type HeadingProps = {
page?: Pages;
segments: HeadingType[];
};
export function Heading({ segments }: HeadingProps) {
export function Heading({ page, segments }: HeadingProps) {
return (
<HeadingWrap>
<HeadingWrap data-page={page}>
<BaseHeading as="h2" segments={segments} size="xl" weight="light" />
</HeadingWrap>
);
@@ -1,7 +1,8 @@
import { styled } from '@linaria/react';
import type { ReactNode } from 'react';
import { Container } from '@/design-system/components';
import { Container, GuideCrosshair } from '@/design-system/components';
import { Pages } from '@/enums/pages';
import { SignoffShape } from '@/sections/Signoff/SignoffShape';
import { theme } from '@/theme';
@@ -14,6 +15,21 @@ const StyledSection = styled.section`
overflow: hidden;
position: relative;
}
@media (min-width: ${theme.breakpoints.md}px) {
&[data-page='partner'] {
min-height: 759px;
overflow: hidden;
position: relative;
}
&[data-page='partner'] > div {
justify-content: center;
min-height: 759px;
padding-bottom: 0;
padding-top: 0;
}
}
`;
const StyledContainer = styled(Container)`
@@ -40,6 +56,7 @@ type RootPropsSimple = {
backgroundColor: string;
children: ReactNode;
color: string;
page?: Pages;
variant?: 'simple';
};
@@ -47,6 +64,7 @@ type RootPropsShaped = {
backgroundColor: string;
children: ReactNode;
color: string;
page?: Pages;
shapeFillColor: string;
variant: 'shaped';
};
@@ -54,18 +72,22 @@ type RootPropsShaped = {
type RootProps = RootPropsSimple | RootPropsShaped;
export function Root(props: RootProps) {
const { backgroundColor, children, color } = props;
const { backgroundColor, children, color, page } = props;
const isShaped = props.variant === 'shaped';
const shapeFillColor = isShaped ? props.shapeFillColor : undefined;
return (
<StyledSection
data-page={page}
data-shaped={isShaped ? '' : undefined}
style={{ backgroundColor, color }}
>
{isShaped && shapeFillColor ? (
<SignoffShape fillColor={shapeFillColor} />
) : null}
{page === Pages.Partner ? (
<GuideCrosshair crossX="calc(50% - 334px)" crossY="198px" />
) : null}
<StyledContainer>{children}</StyledContainer>
</StyledSection>
);
@@ -162,8 +162,7 @@ export function Carousel({ children, eyebrow, testimonials }: CarouselProps) {
const hasPrevious = index > 0;
const hasNext = index < total - 1;
const current = testimonials[index];
const authorSecondaryLine =
current.author.designation ?? current.author.handle ?? null;
const authorSecondaryLine = current.author.designation ?? null;
const goToPrevious = () => {
if (hasPrevious) setIndex(index - 1);
@@ -3,11 +3,11 @@
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 { PartnerEffect } from '@/illustrations/Testimonials/PartnerEffect';
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';
@@ -18,11 +18,15 @@ const DATE_FORMATTER = new Intl.DateTimeFormat('en-US', {
});
const nameTextClassName = css`
color: ${theme.colors.secondary.text[100]};
&&& {
color: ${theme.colors.secondary.text[100]};
}
`;
const handleTextClassName = css`
color: ${theme.colors.secondary.text[100]};
&&& {
color: ${theme.colors.secondary.text[100]};
}
`;
const dateTextClassName = css`
@@ -262,16 +266,10 @@ export function PartnerCarousel({
<AuthorCard>
<PortraitFrame>
{avatar ? (
<NextImage
alt={avatar.alt ?? ''}
fill
priority
sizes="(min-width: 921px) 328px, 100vw"
<PartnerEffect
alt={avatar.alt ?? current.author.name.text}
fallback={authorInitials}
src={avatar.src}
style={{
filter: 'grayscale(1) contrast(1.1)',
objectFit: 'cover',
}}
/>
) : (
<PortraitPlaceholder aria-hidden>
@@ -0,0 +1,427 @@
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import {
IconArrowUpRight,
IconBuildingSkyscraper,
IconChevronDown,
IconChevronLeft,
IconChevronUp,
IconDatabaseExport,
IconDatabaseImport,
IconDotsVertical,
IconHandClick,
IconMail,
IconPlus,
IconSparkles,
IconTargetArrow,
IconTrash,
} from '@tabler/icons-react';
import type { ComponentType } from 'react';
const APP_FONT = `'Inter', ${theme.font.family.sans}`;
const TABLER_STROKE = 1.7;
const COLORS = {
backdrop: '#1b1b1b',
border: '#ebebeb',
imageAreaSurface: '#f5f5f3',
itemBackground: 'rgba(0, 0, 0, 0.04)',
panelBackground: 'rgba(255, 255, 255, 0.96)',
panelMuted: '#fcfcfc',
textLight: '#b3b3b3',
textSecondary: '#666666',
textTertiary: '#999999',
white: '#ffffff',
} as const;
type MenuItemData = {
highlighted?: boolean;
icon: ComponentType<{
'aria-hidden'?: boolean;
color?: string;
size?: number | string;
stroke?: number | string;
}>;
label: string;
};
const MENU_SECTIONS: Array<{ items: MenuItemData[]; label: string }> = [
{
label: 'Record Selection',
items: [
{ icon: IconMail, label: 'Send email' },
{
highlighted: true,
icon: IconDatabaseExport,
label: 'Export selection as CSV',
},
{ icon: IconTrash, label: 'Delete 8 records' },
],
},
{
label: '"Companies" object',
items: [
{ icon: IconDatabaseImport, label: 'Import data' },
{ icon: IconBuildingSkyscraper, label: 'Create company' },
],
},
{
label: 'Navigate',
items: [
{ icon: IconArrowUpRight, label: 'Go to People' },
{ icon: IconTargetArrow, label: 'Go to Opportunities' },
],
},
];
const VisualRoot = styled.div`
background: ${COLORS.imageAreaSurface};
height: 100%;
overflow: hidden;
position: relative;
width: 100%;
`;
const SceneTexture = styled.div`
inset: 0;
opacity: 0.4;
pointer-events: none;
position: absolute;
`;
const TopStripes = styled.div`
background: repeating-linear-gradient(
180deg,
rgba(255, 255, 255, 0.32) 0 2px,
transparent 2px 15px
);
inset: 0 0 64% 0;
position: absolute;
`;
const MidDashes = styled.div`
background: repeating-linear-gradient(
180deg,
rgba(255, 255, 255, 0.26) 0 2px,
transparent 2px 14px
),
repeating-linear-gradient(
90deg,
rgba(255, 255, 255, 0.18) 0 10px,
transparent 10px 15px
);
inset: 34% 0 34% 0;
position: absolute;
`;
const BottomDots = styled.div`
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.14) 0%,
rgba(255, 255, 255, 0.08) 26%,
rgba(255, 255, 255, 0.04) 100%
),
radial-gradient(
circle at center,
rgba(255, 255, 255, 0.18) 0 1px,
transparent 1px 100%
);
background-size:
100% 100%,
12px 12px;
inset: 48% 0 0 0;
position: absolute;
`;
const SceneFade = styled.div`
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.08) 0%,
rgba(255, 255, 255, 0.04) 18%,
rgba(255, 255, 255, 0) 42%,
rgba(0, 0, 0, 0.18) 100%
);
inset: 0;
pointer-events: none;
position: absolute;
`;
const SceneStage = styled.div`
aspect-ratio: 411 / 508;
background: ${COLORS.backdrop};
height: auto;
left: 50%;
overflow: hidden;
position: absolute;
top: 16px;
transform: translateX(-50%);
width: min(calc(100% - 32px), 411px);
`;
const SceneCanvas = styled.div`
inset: 0;
position: absolute;
`;
const SceneFrame = styled.div`
inset: 0;
position: absolute;
`;
const Toolbar = styled.div`
align-items: center;
display: flex;
gap: 8px;
justify-content: flex-end;
position: absolute;
right: 4%;
top: 14.2%;
`;
const ToolbarButton = styled.div<{ $compact?: boolean }>`
align-items: center;
background: rgba(255, 255, 255, 0.9);
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 4px;
box-shadow: 0 1px 2px rgba(28, 28, 28, 0.04);
color: ${COLORS.textSecondary};
display: inline-flex;
flex: ${({ $compact }) => ($compact ? '0 0 auto' : '1 1 auto')};
gap: ${({ $compact }) => ($compact ? '0' : '6px')};
height: 27px;
justify-content: center;
min-width: ${({ $compact }) => ($compact ? '27px' : '0')};
padding: ${({ $compact }) => ($compact ? '0 8px' : '0 12px')};
`;
const ToolbarLabel = styled.span<{ $light?: boolean }>`
color: ${({ $light }) => ($light ? COLORS.textLight : COLORS.textSecondary)};
font-family: ${APP_FONT};
font-size: 13px;
font-weight: 500;
line-height: 1.4;
white-space: nowrap;
`;
const ToolbarSeparator = styled.span`
background: rgba(0, 0, 0, 0.06);
border-radius: 999px;
display: block;
height: 14px;
width: 1px;
`;
const MenuShell = styled.div`
background: rgba(255, 255, 255, 0.12);
border-radius: 9px;
box-shadow:
0 20px 48px rgba(0, 0, 0, 0.28),
0 6px 18px rgba(0, 0, 0, 0.12);
height: 81%;
left: 14%;
overflow: hidden;
position: absolute;
top: 19.1%;
width: 81.5%;
`;
const MenuPanel = styled.div`
background: ${COLORS.panelBackground};
border: 1px solid ${COLORS.border};
border-radius: 9px;
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
position: relative;
`;
const MenuHeader = styled.div`
align-items: center;
background: ${COLORS.panelMuted};
border-bottom: 1px solid ${COLORS.border};
display: flex;
gap: 8px;
padding: 10px 12px;
`;
const HeaderIconWrap = styled.div`
align-items: center;
display: inline-flex;
flex: 0 0 auto;
justify-content: center;
`;
const SearchPlaceholder = styled.div`
color: ${COLORS.textTertiary};
flex: 1 1 auto;
font-family: ${APP_FONT};
font-size: 14px;
font-weight: 400;
line-height: 1.4;
min-width: 0;
`;
const MenuBody = styled.div`
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: 4px;
padding: 6px 8px 16px;
position: relative;
`;
const SectionLabel = styled.div`
color: ${COLORS.textLight};
font-family: ${APP_FONT};
font-size: 11px;
font-weight: 600;
line-height: 1.2;
padding: 10px 4px 4px;
`;
const MenuRow = styled.div<{ $highlighted?: boolean }>`
align-items: center;
background: ${({ $highlighted }) =>
$highlighted ? COLORS.itemBackground : 'transparent'};
border-radius: 4px;
display: flex;
gap: 10px;
min-height: 34px;
padding: 4px;
position: relative;
`;
const RowIconFrame = styled.div`
align-items: center;
background: ${COLORS.itemBackground};
border-radius: 4px;
color: ${COLORS.textSecondary};
display: inline-flex;
flex: 0 0 auto;
height: 24px;
justify-content: center;
width: 24px;
`;
const RowLabel = styled.div`
color: ${COLORS.textSecondary};
font-family: ${APP_FONT};
font-size: 14px;
font-weight: 400;
line-height: 1.4;
min-width: 0;
`;
const HandMarker = styled.div`
bottom: -12px;
color: #111111;
filter: drop-shadow(0 0 2px rgba(255, 255, 255, 0.98))
drop-shadow(0 3px 8px rgba(28, 28, 28, 0.24));
pointer-events: none;
position: absolute;
right: 30px;
z-index: 1;
`;
const FadeOut = styled.div`
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0.92) 55%,
${COLORS.panelBackground} 100%
);
bottom: 0;
height: 54px;
left: 0;
pointer-events: none;
position: absolute;
right: 0;
`;
function renderMenuIcon(
Icon: MenuItemData['icon'],
color: string = COLORS.textSecondary,
) {
return <Icon aria-hidden color={color} size={15} stroke={TABLER_STROKE} />;
}
export function FastPathVisual() {
return (
<VisualRoot aria-hidden>
<SceneStage>
<SceneTexture>
<TopStripes />
<MidDashes />
<BottomDots />
</SceneTexture>
<SceneFade />
<SceneCanvas>
<SceneFrame>
<Toolbar>
<ToolbarButton>
{renderMenuIcon(IconPlus)}
<ToolbarLabel>New Record</ToolbarLabel>
</ToolbarButton>
<ToolbarButton $compact>
{renderMenuIcon(IconChevronUp)}
</ToolbarButton>
<ToolbarButton $compact>
{renderMenuIcon(IconChevronDown)}
</ToolbarButton>
<ToolbarButton>
{renderMenuIcon(IconDotsVertical)}
<ToolbarSeparator />
<ToolbarLabel $light>K</ToolbarLabel>
</ToolbarButton>
</Toolbar>
<MenuShell>
<MenuPanel>
<MenuHeader>
<HeaderIconWrap>
{renderMenuIcon(IconChevronLeft, COLORS.textSecondary)}
</HeaderIconWrap>
<SearchPlaceholder>Type anything...</SearchPlaceholder>
<HeaderIconWrap>
{renderMenuIcon(IconSparkles, COLORS.textSecondary)}
</HeaderIconWrap>
</MenuHeader>
<MenuBody>
{MENU_SECTIONS.map((section) => (
<div key={section.label}>
<SectionLabel>{section.label}</SectionLabel>
{section.items.map((item) => (
<MenuRow
$highlighted={item.highlighted}
key={`${section.label}-${item.label}`}
>
<RowIconFrame>
{renderMenuIcon(item.icon)}
</RowIconFrame>
<RowLabel>{item.label}</RowLabel>
{item.highlighted ? (
<HandMarker>
<IconHandClick
aria-hidden
size={32}
stroke={1.9}
/>
</HandMarker>
) : null}
</MenuRow>
))}
</div>
))}
<FadeOut />
</MenuBody>
</MenuPanel>
</MenuShell>
</SceneFrame>
</SceneCanvas>
</SceneStage>
</VisualRoot>
);
}
@@ -1,9 +1,16 @@
'use client';
import { Body, Heading } from '@/design-system/components';
import { RectangleFillIcon } from '@/icons';
import { ThreeCardsFeatureCardType } from '@/sections/ThreeCards/types';
import { FastPathIcon, LiveDataIcon } from '@/icons';
import type { ThreeCardsFeatureCardType } from '@/sections/ThreeCards/types';
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 { FastPathVisual } from './FastPathVisual';
import { FamiliarInterfaceVisual } from './FamiliarInterfaceVisual';
import { LiveDataVisual } from './LiveDataVisual';
const FeatureCardContainer = styled.div`
display: grid;
@@ -26,10 +33,11 @@ const CardImage = styled.div`
`;
const CardContent = styled.div`
background-color: ${theme.colors.secondary.background[5]};
display: grid;
grid-template-columns: 1fr;
row-gap: ${theme.spacing(4)};
padding: ${theme.spacing(4)};
row-gap: ${theme.spacing(3)};
padding: ${theme.spacing(3.5)} ${theme.spacing(4)} ${theme.spacing(4)};
`;
const CardTitleRow = styled.div`
@@ -46,33 +54,109 @@ const CardIcon = styled.div`
height: 22px;
`;
const CardTitleWrap = styled.div`
color: ${theme.colors.primary.text[100]};
min-width: 0;
`;
const UsersGroupMarker = styled.div`
align-items: center;
background: ${theme.colors.highlight[100]};
border-radius: ${theme.radius(1)};
color: ${theme.colors.primary.background[100]};
display: flex;
height: 22px;
justify-content: center;
width: 22px;
`;
const CardIconMarker = styled.div`
align-items: center;
background: ${theme.colors.highlight[100]};
border-radius: ${theme.radius(1)};
display: flex;
height: 22px;
justify-content: center;
width: 22px;
`;
type FeatureCardProps = { featureCard: ThreeCardsFeatureCardType };
function renderFeatureCardIcon(icon: ThreeCardsFeatureCardType['icon']) {
const strokeColor = theme.colors.primary.background[100];
switch (icon) {
case 'users-group':
return (
<UsersGroupMarker>
<IconUsersGroup aria-hidden size={14} stroke={2} />
</UsersGroupMarker>
);
case 'fast-path':
return (
<CardIconMarker>
<FastPathIcon size={14.667} strokeColor={strokeColor} />
</CardIconMarker>
);
case 'live-data':
return (
<CardIconMarker>
<LiveDataIcon size={14.667} strokeColor={strokeColor} />
</CardIconMarker>
);
}
return null;
}
export function FeatureCard({ featureCard }: FeatureCardProps) {
const [isHovered, setIsHovered] = useState(false);
let visual: ReactNode;
if ('illustration' in featureCard) {
switch (featureCard.illustration) {
case 'familiar-interface':
visual = <FamiliarInterfaceVisual active={isHovered} />;
break;
case 'fast-path':
visual = <FastPathVisual />;
break;
case 'live-data':
visual = <LiveDataVisual active={isHovered} />;
break;
}
} else {
visual = (
<Image
src={featureCard.image.src}
alt={featureCard.image.alt}
fill
style={{ objectFit: 'cover' }}
/>
);
}
return (
<FeatureCardContainer>
<CardImage>
<Image
src={featureCard.image.src}
alt={featureCard.image.alt}
fill
style={{ objectFit: 'cover' }}
/>
</CardImage>
<FeatureCardContainer
onPointerEnter={() => {
setIsHovered(true);
}}
onPointerLeave={() => {
setIsHovered(false);
}}
>
<CardImage>{visual}</CardImage>
<CardContent>
<CardTitleRow>
<CardIcon>
<RectangleFillIcon
size={14}
fillColor={theme.colors.highlight[100]}
<CardIcon>{renderFeatureCardIcon(featureCard.icon)}</CardIcon>
<CardTitleWrap>
<Heading
as="h3"
segments={featureCard.heading}
size="xs"
weight="medium"
/>
</CardIcon>
<Heading
as="h3"
segments={featureCard.heading}
size="xs"
weight="medium"
/>
</CardTitleWrap>
</CardTitleRow>
<Body body={featureCard.body} size="sm" weight="regular" />
</CardContent>
@@ -0,0 +1,627 @@
'use client';
import { SHARED_COMPANY_LOGO_URLS } from '@/lib/shared-asset-paths';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import {
IconBuildingSkyscraper,
IconCheckbox,
IconChevronDown,
IconLink,
IconPlus,
} from '@tabler/icons-react';
import {
useEffect,
useRef,
useState,
type PointerEvent as ReactPointerEvent,
type ReactNode,
} from 'react';
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 COLORS = {
accentBorder: VISUAL_TOKENS.border.color.blue,
accentSurfaceSoft: VISUAL_TOKENS.background.transparent.blue,
background: VISUAL_TOKENS.background.primary,
backgroundSecondary: VISUAL_TOKENS.background.secondary,
borderLight: VISUAL_TOKENS.border.color.light,
borderMedium: VISUAL_TOKENS.border.color.medium,
borderStrong: VISUAL_TOKENS.border.color.strong,
green: '#18794e',
greenBorder: '#ddf3e4',
greenSurface: '#ddf3e4',
purple: '#793aaf',
purpleBorder: '#f3e7fc',
purpleSurface: '#f3e7fc',
text: VISUAL_TOKENS.font.color.primary,
textLight: VISUAL_TOKENS.font.color.light,
textSecondary: VISUAL_TOKENS.font.color.secondary,
textTertiary: VISUAL_TOKENS.font.color.tertiary,
} as const;
const TABLE_COLUMNS = [
{ id: 'company', isFirstColumn: true, label: 'Companies', width: 180 },
{ id: 'type', isFirstColumn: false, label: 'Type', width: 160 },
{ id: 'url', isFirstColumn: false, label: 'Domain', width: 150 },
] as const;
const TABLE_ROWS = [
{
company: 'Slack',
domain: 'slack.com',
logoSrc: SHARED_COMPANY_LOGO_URLS.slack,
status: 'Customer',
},
{
company: 'Notion',
domain: 'notion.so',
logoSrc: SHARED_COMPANY_LOGO_URLS.notion,
status: 'Customer',
},
{
company: 'Sequoia',
domain: 'sequoiacap.com',
logoSrc: SHARED_COMPANY_LOGO_URLS.sequoia,
status: 'Customer',
},
] as const;
const TableShell = styled.div`
display: flex;
flex: 1 1 auto;
min-height: 0;
min-width: 0;
overflow: hidden;
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;
min-height: 0;
min-width: 0;
overflow-x: auto;
overflow-y: hidden;
overscroll-behavior-x: contain;
scrollbar-width: none;
width: 100%;
&::-webkit-scrollbar {
display: none;
}
`;
const TableCanvas = styled.div<{ $width: number }>`
display: flex;
flex-direction: column;
min-height: 100%;
min-width: ${({ $width }) => `${$width}px`};
width: ${({ $width }) => `${$width}px`};
`;
const HeaderRow = styled.div`
display: flex;
`;
const DataRow = styled.div`
display: flex;
`;
const FooterRow = styled.div`
display: flex;
`;
const TableCell = styled.div<{
$header?: boolean;
$hovered?: boolean;
$sticky?: boolean;
$width: number;
}>`
align-items: center;
background: ${({ $header, $hovered }) => {
if ($header) {
return COLORS.background;
}
return $hovered ? COLORS.backgroundSecondary : COLORS.background;
}};
border-bottom: 1px solid ${COLORS.borderLight};
border-right: 1px solid ${COLORS.borderLight};
box-sizing: border-box;
display: flex;
flex: 0 0 ${({ $width }) => `${$width}px`};
height: 32px;
left: ${({ $sticky }) => ($sticky ? '0' : 'auto')};
min-width: ${({ $width }) => `${$width}px`};
padding: 0 ${TABLE_CELL_HORIZONTAL_PADDING}px;
position: ${({ $sticky }) => ($sticky ? 'sticky' : 'relative')};
z-index: ${({ $header, $sticky }) => {
if ($sticky && $header) {
return 6;
}
if ($sticky) {
return 4;
}
return 1;
}};
`;
const EmptyFillCell = styled.div<{
$footer?: boolean;
$header?: boolean;
$hovered?: boolean;
$width: number;
}>`
background: ${({ $header, $hovered, $footer }) => {
if ($header || $footer) {
return COLORS.background;
}
return $hovered ? COLORS.backgroundSecondary : COLORS.background;
}};
border-bottom: 1px solid ${COLORS.borderLight};
flex: 0 0 ${({ $width }) => `${$width}px`};
min-width: ${({ $width }) => `${$width}px`};
`;
const HeaderCellContent = styled.div`
align-items: center;
display: flex;
gap: 4px;
height: 100%;
min-width: 0;
width: 100%;
`;
const HeaderLabel = styled.span`
color: ${COLORS.textTertiary};
font-family: ${APP_FONT};
font-size: 13px;
font-weight: ${theme.font.weight.medium};
line-height: 1.4;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const EdgePlus = styled.div`
margin-left: auto;
`;
const CheckboxContainer = styled.div`
align-items: center;
display: flex;
flex: 0 0 24px;
height: 24px;
justify-content: center;
width: 24px;
`;
const CheckboxBox = styled.div`
align-items: center;
background: transparent;
border: 1px solid ${COLORS.borderStrong};
border-radius: 3px;
display: flex;
height: 14px;
justify-content: center;
width: 14px;
`;
const CellHoverAnchor = styled.div`
align-items: center;
display: flex;
height: 100%;
min-width: 0;
position: relative;
width: 100%;
`;
const CellChip = styled(Chip)`
max-width: 100%;
min-width: 0;
`;
const FooterFirstContent = styled.div`
align-items: center;
display: inline-flex;
gap: 4px;
padding-left: 28px;
`;
const HeaderFillContent = styled.div`
align-items: center;
display: flex;
height: 100%;
padding: 0 8px;
`;
const MutedText = styled.span`
color: ${COLORS.textTertiary};
font-family: ${APP_FONT};
font-size: 13px;
font-weight: ${theme.font.weight.regular};
line-height: 1.4;
white-space: nowrap;
`;
const LogoBase = styled.div`
align-items: center;
display: inline-flex;
flex: 0 0 auto;
height: 14px;
justify-content: center;
overflow: hidden;
width: 14px;
`;
const CompanyLogoImage = styled.img`
display: block;
height: 100%;
object-fit: contain;
width: 14px;
`;
const StatusChip = styled.div<{ $edited?: boolean; $hoveredByAlice?: boolean }>`
align-items: center;
background: ${({ $edited }) =>
$edited ? COLORS.purpleSurface : COLORS.greenSurface};
border-radius: 4px;
box-shadow: inset 0 0 0 1px
${({ $edited }) => ($edited ? COLORS.purpleBorder : COLORS.greenBorder)};
color: ${({ $edited }) => ($edited ? COLORS.purple : COLORS.green)};
display: inline-flex;
font-family: ${APP_FONT};
font-size: ${VISUAL_TOKENS.font.size.md};
font-weight: ${VISUAL_TOKENS.font.weight.regular};
height: 20px;
justify-content: center;
line-height: 1.4;
padding: 3px ${VISUAL_TOKENS.spacing[1]};
transform: ${({ $hoveredByAlice }) =>
$hoveredByAlice ? 'scale(1.1)' : 'scale(1)'};
transform-origin: center;
transition:
background-color 180ms ease,
box-shadow 180ms ease,
color 180ms ease,
transform 180ms ease,
width 180ms ease;
white-space: nowrap;
`;
function HeaderIcon({ columnId }: { columnId: (typeof TABLE_COLUMNS)[number]['id'] }) {
if (columnId === 'url') {
return (
<IconLink
aria-hidden
color={COLORS.textTertiary}
size={16}
stroke={TABLER_STROKE}
/>
);
}
if (columnId === 'type') {
return (
<IconCheckbox
aria-hidden
color={COLORS.textTertiary}
size={16}
stroke={TABLER_STROKE}
/>
);
}
return (
<IconBuildingSkyscraper
aria-hidden
color={COLORS.textTertiary}
size={16}
stroke={TABLER_STROKE}
/>
);
}
function ChevronDownMini() {
return (
<IconChevronDown
aria-hidden
color={COLORS.textTertiary}
size={14}
stroke={TABLER_STROKE}
/>
);
}
function PlusMini({ size = 12 }: { size?: number }) {
return (
<IconPlus
aria-hidden
color={COLORS.textTertiary}
size={size}
stroke={TABLER_STROKE}
/>
);
}
function CompanyCell({
label,
logoSrc,
}: {
label: string;
logoSrc: string;
}) {
return (
<CellHoverAnchor>
<CheckboxContainer>
<CheckboxBox />
</CheckboxContainer>
<CellChip
clickable={false}
label={label}
leftComponent={
<LogoBase>
<CompanyLogoImage alt="" decoding="async" loading="lazy" src={logoSrc} />
</LogoBase>
}
variant={ChipVariant.Highlighted}
/>
</CellHoverAnchor>
);
}
function LinkCell({ label }: { label: string }) {
return (
<div style={{ minWidth: 0, position: 'relative', width: '100%' }}>
<CellChip clickable={false} label={label} variant={ChipVariant.Static} />
</div>
);
}
type LiveDataHeroTableProps = {
editedStatusLabel: string;
isFirstTagEdited: boolean;
isFirstTagHoveredByAlice: boolean;
};
export function LiveDataHeroTable({
editedStatusLabel,
isFirstTagEdited,
isFirstTagHoveredByAlice,
}: LiveDataHeroTableProps) {
const viewportRef = useRef<HTMLDivElement>(null);
const dragRef = useRef({
active: false,
pointerId: -1,
startScrollLeft: 0,
startX: 0,
});
const [dragging, setDragging] = useState(false);
const [hoveredRowIndex, setHoveredRowIndex] = useState<number | null>(null);
const columnWidth = TABLE_COLUMNS.reduce(
(sum, column) => sum + column.width,
0,
);
const totalTableWidth = Math.max(DEFAULT_TABLE_WIDTH, columnWidth);
const fillerWidth = Math.max(totalTableWidth - columnWidth, 0);
const handlePointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
if (
event.pointerType !== 'mouse' ||
event.button !== 0 ||
!viewportRef.current
) {
return;
}
dragRef.current = {
active: true,
pointerId: event.pointerId,
startScrollLeft: viewportRef.current.scrollLeft,
startX: event.clientX,
};
viewportRef.current.setPointerCapture(event.pointerId);
setDragging(true);
event.preventDefault();
};
const handlePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
if (!dragRef.current.active || !viewportRef.current) {
return;
}
viewportRef.current.scrollLeft =
dragRef.current.startScrollLeft -
(event.clientX - dragRef.current.startX);
};
const endDragging = () => {
dragRef.current.active = false;
dragRef.current.pointerId = -1;
setDragging(false);
};
const handlePointerUp = (event: ReactPointerEvent<HTMLDivElement>) => {
if (!viewportRef.current || dragRef.current.pointerId !== event.pointerId) {
return;
}
viewportRef.current.releasePointerCapture(event.pointerId);
endDragging();
};
useEffect(() => {
const node = viewportRef.current;
if (!node) {
return;
}
const onWheel: EventListener = (event) => {
if (!(event instanceof WheelEvent)) {
return;
}
if (Math.abs(event.deltaY) <= Math.abs(event.deltaX)) {
return;
}
const maxScrollLeft = Math.max(node.scrollWidth - node.clientWidth, 0);
const nextScrollLeft = Math.min(
Math.max(node.scrollLeft + event.deltaY, 0),
maxScrollLeft,
);
if (Math.abs(nextScrollLeft - node.scrollLeft) < 0.5) {
return;
}
node.scrollLeft = nextScrollLeft;
event.preventDefault();
};
node.addEventListener('wheel', onWheel, { passive: false });
return () => {
node.removeEventListener('wheel', onWheel);
};
}, []);
return (
<TableShell>
<GripRail aria-hidden="true">
<GripCell />
{TABLE_ROWS.map((row) => (
<GripCell key={`grip-${row.company}`} />
))}
<GripCell />
</GripRail>
<TableViewport
ref={viewportRef}
$dragging={dragging}
aria-label="Interactive preview of the companies table"
onPointerCancel={endDragging}
onPointerDown={handlePointerDown}
onPointerLeave={endDragging}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
>
<TableCanvas $width={totalTableWidth}>
<HeaderRow>
{TABLE_COLUMNS.map((column) => (
<TableCell
key={column.id}
$header
$sticky={column.isFirstColumn}
$width={column.width}
>
<HeaderCellContent>
{column.isFirstColumn ? (
<>
<CheckboxContainer>
<CheckboxBox />
</CheckboxContainer>
<HeaderIcon columnId={column.id} />
<HeaderLabel>{column.label}</HeaderLabel>
<EdgePlus aria-hidden="true">
<PlusMini />
</EdgePlus>
</>
) : (
<>
<HeaderIcon columnId={column.id} />
<HeaderLabel>{column.label}</HeaderLabel>
</>
)}
</HeaderCellContent>
</TableCell>
))}
<EmptyFillCell $header $width={fillerWidth}>
{fillerWidth > 0 ? (
<HeaderFillContent>
<PlusMini size={16} />
</HeaderFillContent>
) : null}
</EmptyFillCell>
</HeaderRow>
{TABLE_ROWS.map((row, index) => {
const hovered = hoveredRowIndex === index;
return (
<DataRow
key={row.company}
onMouseEnter={() => setHoveredRowIndex(index)}
onMouseLeave={() =>
setHoveredRowIndex((current) =>
current === index ? null : current,
)
}
>
<TableCell
$hovered={hovered}
$sticky
$width={TABLE_COLUMNS[0].width}
>
<CompanyCell label={row.company} logoSrc={row.logoSrc} />
</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>
</TableCell>
<TableCell $hovered={hovered} $width={TABLE_COLUMNS[2].width}>
<LinkCell label={row.domain} />
</TableCell>
<EmptyFillCell $hovered={hovered} $width={fillerWidth} />
</DataRow>
);
})}
<FooterRow>
<TableCell $sticky $width={TABLE_COLUMNS[0].width}>
<FooterFirstContent>
<MutedText>Calculate</MutedText>
<ChevronDownMini />
</FooterFirstContent>
</TableCell>
<TableCell $width={TABLE_COLUMNS[1].width} />
<TableCell $width={TABLE_COLUMNS[2].width} />
<EmptyFillCell $footer $width={fillerWidth} />
</FooterRow>
</TableCanvas>
</TableViewport>
</TableShell>
);
}
@@ -0,0 +1,796 @@
'use client';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import {
IconCheckbox,
IconChevronDown,
IconList,
IconPlus,
IconX,
} from '@tabler/icons-react';
import { useEffect, useState } from 'react';
import { LiveDataHeroTable } from './LiveDataHeroTable';
const APP_FONT = `'Inter', ${theme.font.family.sans}`;
const SCENE_HEIGHT = 508;
const SCENE_WIDTH = 411;
const SCENE_SCALE = 1;
const SCENE_SCALE_MD = 0.86;
const SCENE_SCALE_SM = 0.74;
const TABLER_STROKE = 1.65;
const FILTER_ICON_STROKE = 1.33;
const COLORS = {
backdrop: '#1b1b1b',
backdropDot: 'rgba(255, 255, 255, 0.14)',
backdropStripe: 'rgba(255, 255, 255, 0.20)',
black: '#1f1f1f',
blue: '#1961ed',
blueBorder: '#edf2fe',
blueSurface: '#f5f9fd',
cardSurface: '#f5f5f3',
green: '#18794e',
greenSurface: '#ddf3e4',
muted: '#999999',
orange: '#ffb08d',
purple: '#7869ff',
text: '#333333',
textLight: '#b3b3b3',
textSecondary: '#666666',
white: '#ffffff',
yellow: '#fff6a5',
} as const;
type LiveDataPhase =
| 'idle'
| 'move-to-tag'
| 'rename-tag'
| 'settle';
const LIVE_DATA_SEQUENCE: Array<{
delay: number;
phase: Exclude<LiveDataPhase, 'idle'>;
}> = [
{ delay: 0, phase: 'move-to-tag' },
{ delay: 760, phase: 'rename-tag' },
{ delay: 1860, phase: 'settle' },
];
const LIVE_DATA_LOOP_DURATION = 3200;
const EDITED_TAG_LABEL = 'Priority';
const TYPING_STEP_MS = 90;
const LIVE_DATA_CURSOR_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 },
};
const VisualRoot = styled.div`
background: ${COLORS.cardSurface};
height: 100%;
overflow: hidden;
position: relative;
width: 100%;
`;
const SceneViewport = styled.div`
height: ${SCENE_HEIGHT}px;
left: 50%;
position: absolute;
top: 16px;
transform: translateX(-50%) scale(${SCENE_SCALE});
transform-origin: top center;
width: ${SCENE_WIDTH}px;
@media (max-width: ${theme.breakpoints.md - 1}px) {
transform: translateX(-50%) scale(${SCENE_SCALE_MD});
}
@media (max-width: 640px) {
transform: translateX(-50%) scale(${SCENE_SCALE_SM});
}
`;
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;
position: absolute;
top: 67%;
width: 58px;
`;
const TablePanel = styled.div`
background: ${COLORS.white};
border-top-left-radius: ${theme.radius(1)};
box-shadow: 0 0 0 1px rgba(241, 241, 241, 0.9);
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
height: 341px;
left: 58px;
overflow: hidden;
position: absolute;
top: 151px;
width: 355px;
`;
const ViewRow = styled.div`
align-items: center;
border-bottom: 1px solid #f1f1f1;
display: flex;
height: 40px;
padding: 0 8px;
`;
const ViewSwitcher = styled.div`
align-items: center;
border-radius: 4px;
cursor: pointer;
display: inline-flex;
gap: 4px;
height: 24px;
padding: 0 4px;
transition: background-color 120ms ease;
&:hover {
background: rgba(0, 0, 0, 0.04);
}
`;
const ViewSwitcherLeft = styled.div`
align-items: center;
display: inline-flex;
gap: 4px;
height: 100%;
`;
const ViewSwitcherIcon = styled.span`
align-items: center;
display: inline-flex;
flex: 0 0 auto;
height: 16px;
justify-content: center;
width: 16px;
`;
const ViewLabel = styled.span`
color: ${COLORS.textSecondary};
font-family: ${APP_FONT};
font-size: 13px;
font-weight: ${theme.font.weight.medium};
line-height: 1.4;
white-space: nowrap;
`;
const ViewDot = styled.div`
background: ${COLORS.textLight};
border-radius: 999px;
height: 2px;
width: 2px;
`;
const ViewCount = styled.span`
color: ${COLORS.textLight};
font-family: ${APP_FONT};
font-size: 13px;
font-weight: ${theme.font.weight.medium};
line-height: 1.4;
white-space: nowrap;
`;
const ViewSwitcherChevron = styled.span`
align-items: center;
display: inline-flex;
flex: 0 0 auto;
height: 14px;
justify-content: center;
width: 14px;
`;
const FilterRow = styled.div`
align-items: center;
border-bottom: 1px solid #f1f1f1;
display: flex;
gap: 4px;
height: 48px;
padding: 0 12px;
`;
const FilterChip = styled.div<{ $pressed?: boolean }>`
align-items: center;
background: ${COLORS.blueSurface};
border: 1px solid ${COLORS.blueBorder};
border-radius: 4px;
box-shadow: ${({ $pressed }) =>
$pressed ? 'inset 0 0 0 1px rgba(25, 97, 237, 0.08)' : 'none'};
color: ${COLORS.blue};
display: inline-flex;
font-family: ${APP_FONT};
font-size: 12px;
gap: 2px;
height: 24px;
line-height: 1.4;
padding: 2px 2px 2px 4px;
transform: ${({ $pressed }) => ($pressed ? 'scale(0.985)' : 'scale(1)')};
transition:
box-shadow 180ms ease,
transform 180ms ease;
white-space: nowrap;
`;
const FilterChipLabel = styled.span`
align-items: center;
display: inline-flex;
gap: 4px;
`;
const FilterChipIcon = styled.span`
align-items: center;
display: inline-flex;
flex: 0 0 auto;
height: 14px;
justify-content: center;
width: 14px;
`;
const FilterName = styled.span`
font-size: 12px;
line-height: 1.4;
font-weight: ${theme.font.weight.medium};
`;
const FilterValue = styled.span`
font-size: 12px;
line-height: 1.4;
font-weight: ${theme.font.weight.regular};
`;
const FilterCloseButton = styled.button`
align-items: center;
background: transparent;
border: 0;
border-radius: 2px;
color: ${COLORS.blue};
cursor: pointer;
display: inline-flex;
flex: 0 0 auto;
height: 20px;
justify-content: center;
padding: 0;
width: 20px;
`;
const AddFilter = styled.button`
align-items: center;
background: transparent;
border: 0;
border-radius: 4px;
color: ${COLORS.muted};
cursor: pointer;
display: inline-flex;
font-family: ${APP_FONT};
font-size: 13px;
font-weight: ${theme.font.weight.regular};
gap: 4px;
height: 24px;
line-height: 1.4;
margin-left: 1px;
padding: 0 8px;
transition: background-color 120ms ease;
white-space: nowrap;
&:hover {
background: rgba(0, 0, 0, 0.04);
}
`;
const TableBodyArea = styled.div`
min-height: 0;
min-width: 0;
overflow: hidden;
`;
const MarkerText = styled.span`
color: ${COLORS.black};
font-family: ${theme.font.family.mono};
font-size: 20px;
font-weight: ${theme.font.weight.medium};
line-height: 17.011px;
text-transform: uppercase;
`;
const CursorGlyph = styled.svg<{
$height: number;
$rotation: number;
$width: number;
}>`
display: block;
filter: drop-shadow(0 2px 4px rgba(28, 28, 28, 0.12));
height: ${({ $height }) => `${$height}px`};
transform: ${({ $rotation }) => `rotate(${$rotation}deg)`};
transform-origin: center;
transition: transform 140ms ease;
width: ${({ $width }) => `${$width}px`};
`;
const AnimatedCursorLayer = 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;
z-index: 9;
@keyframes cursor-pop {
0% {
transform: translate(-50%, -50%) scale(1);
}
38% {
transform: translate(-50%, -50%) scale(1.16);
}
68% {
transform: translate(-50%, -50%) scale(0.93);
}
100% {
transform: translate(-50%, -50%) scale(1);
}
}
`;
const AnimatedNameTagLayer = styled.div<{
$left: number;
$top: number;
}>`
left: ${({ $left }) => `${$left}px`};
pointer-events: none;
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);
z-index: 10;
`;
const AliceLabel = styled.div`
align-items: center;
background: ${COLORS.yellow};
border-radius: 4px;
display: inline-flex;
height: 38px;
justify-content: center;
min-width: 86px;
padding: 0 12px;
`;
const TomMarker = styled.div`
left: 31px;
pointer-events: auto;
position: absolute;
top: 306px;
height: 112px;
width: 92px;
z-index: 8;
`;
const TomMarkerMotion = styled.div<{ $hovered: boolean }>`
align-items: flex-start;
display: flex;
flex-direction: column;
gap: 10px;
transform: ${({ $hovered }) =>
$hovered ? 'translate3d(12px, -14px, 0)' : 'translate3d(0, 0, 0)'};
transition: transform 260ms cubic-bezier(0.22, 1, 0.36, 1);
width: 60px;
will-change: transform;
`;
const TomArrow = styled.div`
align-self: flex-end;
align-items: center;
display: flex;
height: 41px;
justify-content: center;
width: 38px;
z-index: 2;
`;
const TomLabel = styled.div`
align-items: center;
background: ${COLORS.orange};
border-radius: 4px;
box-sizing: border-box;
display: inline-flex;
height: 38px;
justify-content: center;
padding: 0 10px;
position: relative;
width: 60px;
z-index: 1;
`;
const TomCursorGlyph = styled.svg`
display: block;
filter: drop-shadow(0 2px 4px rgba(28, 28, 28, 0.12));
height: 38px;
transform: rotate(-90deg) scaleY(-1);
transform-origin: center;
width: 41px;
`;
const BobMarker = styled.div`
pointer-events: auto;
left: 214px;
position: absolute;
top: 386px;
height: 112px;
width: 96px;
z-index: 8;
`;
const BobMarkerMotion = styled.div<{ $hovered: boolean }>`
transform: ${({ $hovered }) =>
$hovered ? 'translate3d(-14px, -16px, 0)' : 'translate3d(0, 0, 0)'};
transition: transform 260ms cubic-bezier(0.22, 1, 0.36, 1);
will-change: transform;
`;
const BobArrow = styled.div`
margin-left: 20px;
position: relative;
z-index: 2;
`;
const BobLabel = styled.div`
align-items: center;
background: ${COLORS.purple};
border-radius: 4px;
display: inline-flex;
height: 38px;
justify-content: center;
margin-top: 10px;
min-width: 60px;
padding: 0 12px;
position: relative;
z-index: 1;
`;
function MarkerCursor({
color,
height = 32,
rotation,
width = 29,
}: {
color: string;
height?: number;
rotation: number;
width?: number;
}) {
return (
<CursorGlyph
$height={height}
$rotation={rotation}
$width={width}
fill="none"
viewBox="0 0 36 40"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19.0238 38.4628C19.5211 39.3742 20.8559 39.2915 21.2369 38.3257L35.6951 1.67609C36.0779 0.70584 35.1494 -0.267942 34.162 0.0682245L18.411 5.43091C18.2041 5.50136 17.9821 5.51512 17.768 5.47075L1.47559 2.09311C0.454282 1.88138 -0.346937 2.96232 0.152654 3.87791L19.0238 38.4628Z"
fill={color}
/>
</CursorGlyph>
);
}
function TomCursor() {
return (
<TomCursorGlyph
fill="none"
viewBox="0 0 40.7459 37.7835"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M39.2312 0.0359766C40.2403 -0.208095 41.0759 0.836037 40.6166 1.76715L23.188 37.1009C22.7265 38.0363 21.3815 38.003 20.967 37.0458L14.3553 21.777C14.2684 21.5764 14.1294 21.4027 13.9528 21.274L0.503957 11.477C-0.339096 10.8629 -0.0768831 9.54315 0.936912 9.29795L39.2312 0.0359766Z"
fill={COLORS.orange}
/>
</TomCursorGlyph>
);
}
type LiveDataVisualProps = {
active?: boolean;
};
export function LiveDataVisual({ active = false }: LiveDataVisualProps) {
const [isBobHovered, setIsBobHovered] = useState(false);
const [isTomHovered, setIsTomHovered] = useState(false);
const [phase, setPhase] = useState<LiveDataPhase>('idle');
const [typedTagLabel, setTypedTagLabel] = useState('');
useEffect(() => {
if (!active) {
setPhase('idle');
return;
}
let cancelled = false;
const timeoutIds: number[] = [];
const runSequence = () => {
setPhase('move-to-tag');
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();
return () => {
cancelled = true;
for (const timeoutId of timeoutIds) {
window.clearTimeout(timeoutId);
}
};
}, [active]);
useEffect(() => {
if (phase === 'rename-tag') {
setTypedTagLabel(EDITED_TAG_LABEL.slice(0, 1));
let nextIndex = 2;
const intervalId = window.setInterval(() => {
setTypedTagLabel(EDITED_TAG_LABEL.slice(0, nextIndex));
if (nextIndex >= EDITED_TAG_LABEL.length) {
window.clearInterval(intervalId);
return;
}
nextIndex += 1;
}, TYPING_STEP_MS);
return () => {
window.clearInterval(intervalId);
};
}
if (phase === 'settle') {
setTypedTagLabel(EDITED_TAG_LABEL);
return;
}
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';
return (
<VisualRoot aria-hidden>
<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}
/>
</AnimatedCursorLayer>
<TablePanel>
<ViewRow>
<ViewSwitcher>
<ViewSwitcherLeft>
<ViewSwitcherIcon>
<IconList
aria-hidden
color={COLORS.textSecondary}
size={16}
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>
<FilterRow>
<FilterChip>
<FilterChipLabel>
<FilterChipIcon>
<IconCheckbox
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>
<FilterChip>
<FilterChipLabel>
<FilterChipIcon>
<IconCheckbox
aria-hidden
color={COLORS.blue}
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>
<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}
/>
</TableBodyArea>
</TablePanel>
<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>
</SceneFrame>
</SceneViewport>
</VisualRoot>
);
}
@@ -43,7 +43,7 @@ const CardEmbed = styled.div`
`;
const CardFooter = styled.footer`
align-items: start;
align-items: center;
column-gap: ${theme.spacing(2)};
display: grid;
grid-template-columns: auto auto auto 1fr;
@@ -66,10 +66,6 @@ const CardBodyCell = styled.div`
min-width: 0;
`;
const CardBody = styled(Body)`
color: ${theme.colors.primary.text[80]};
`;
type IllustrationCardProps = {
illustrationCard: ThreeCardsIllustrationCardType;
variant?: 'shaped' | 'simple';
@@ -99,7 +95,7 @@ export function IllustrationCard({
</CardEmbed>
<CardRule />
<CardBodyCell>
<CardBody body={illustrationCard.body} size="sm" weight="regular" />
<Body body={illustrationCard.body} size="sm" weight="regular" />
</CardBodyCell>
{illustrationCard.attribution && (
@@ -1,9 +1,22 @@
import { BodyType } from '@/design-system/components/Body/types/Body';
import { HeadingType } from '@/design-system/components/Heading/types/Heading';
import { ImageType } from '@/design-system/components/Image/types/Image';
import type { BodyType } from '@/design-system/components/Body/types/Body';
import type { HeadingType } from '@/design-system/components/Heading/types/Heading';
import type { ImageType } from '@/design-system/components/Image/types/Image';
export type ThreeCardsFeatureCardType = {
type ThreeCardsFeatureCardBaseType = {
heading: HeadingType;
body: BodyType;
image: ImageType;
icon: 'fast-path' | 'live-data' | 'users-group';
};
type ThreeCardsFeatureCardImageType = {
image: ImageType;
illustration?: never;
};
type ThreeCardsFeatureCardIllustrationType = {
illustration: 'familiar-interface' | 'fast-path' | 'live-data';
image?: never;
};
export type ThreeCardsFeatureCardType = ThreeCardsFeatureCardBaseType &
(ThreeCardsFeatureCardImageType | ThreeCardsFeatureCardIllustrationType);