More website fixes. (#19489)

This PR fixes most of the visual issues with home page and pricing page.
I think it would be a good checkpoint to merge.
This commit is contained in:
Abdullah.
2026-04-09 17:41:14 +05:00
committed by GitHub
parent c76824e69f
commit 37acd15033
28 changed files with 641 additions and 364 deletions
File diff suppressed because one or more lines are too long
@@ -152,10 +152,63 @@ export const PLAN_TABLE_DATA: PlanTableDataType = {
},
type: 'row',
},
{
featureLabel: 'Custom fields',
tiers: {
organization: { kind: 'text', text: 'Unlimited' },
pro: { kind: 'text', text: 'Unlimited' },
},
type: 'row',
},
{
featureLabel: 'API access',
tiers: {
organization: { kind: 'yes', label: 'Yes' },
pro: { kind: 'yes', label: 'Yes' },
},
type: 'row',
},
{
featureLabel: 'Webhooks',
tiers: {
organization: { kind: 'yes', label: 'Yes' },
pro: { kind: 'yes', label: 'Yes' },
},
type: 'row',
},
{
title: 'Security',
type: 'category',
},
{
featureLabel: 'SSO / SAML',
tiers: {
organization: { kind: 'yes', label: 'Yes' },
pro: { kind: 'dash' },
},
type: 'row',
},
{
featureLabel: 'Audit logs',
tiers: {
organization: { kind: 'yes', label: 'Yes' },
pro: { kind: 'dash' },
},
type: 'row',
},
{
featureLabel: 'Role-based access control',
tiers: {
organization: { kind: 'yes', label: 'Yes' },
pro: { kind: 'dash' },
},
type: 'row',
},
],
initialVisibleRowCount: 13,
seeMoreFeaturesCta: {
href: '/pricing',
label: 'See more features',
collapseLabel: 'Show less',
expandLabel: 'See more features',
},
tierColumns: [
{ id: 'pro', label: 'Pro' },
@@ -32,7 +32,7 @@ export default async function PricingPage() {
return (
<>
<Menu.Root
backgroundColor={theme.colors.secondary.background[5]}
backgroundColor="#F3F3F3"
scheme="primary"
navItems={MENU_DATA.navItems}
socialLinks={menuSocialLinks}
@@ -209,7 +209,6 @@ export function WhyTwenty() {
canvas.style.display = 'block';
canvas.style.height = '100%';
canvas.style.width = '100%';
canvas.style.cursor = 'crosshair';
container.appendChild(canvas);
const clock = new THREE.Clock();
@@ -235,7 +235,6 @@ export function Logo() {
renderer.outputColorSpace = THREE.SRGBColorSpace;
const canvas = renderer.domElement;
canvas.style.cursor = 'crosshair';
canvas.style.display = 'block';
canvas.style.height = '100%';
canvas.style.width = '100%';
@@ -3,15 +3,13 @@
import { Eyebrow, Heading } from '@/design-system/components';
import { HelpedSceneScrollLayoutEffect } from '@/sections/Helped/effect-components/HelpedSceneScrollLayoutEffect';
import type { HelpedDataType } from '@/sections/Helped/types/HelpedData';
import { CARD_WIDTH_DESKTOP } from '@/sections/Helped/utils/helped-scene-layout';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import { useRef } from 'react';
import { Card } from '../Card/Card';
const SCROLL_HEIGHT_VH = 420;
const ScrollStage = styled.section`
height: 280vh;
position: relative;
width: 100%;
`;
@@ -54,8 +52,7 @@ const CardsLayer = styled.div`
const CardPositioner = styled.div`
position: absolute;
width: min(${CARD_WIDTH_DESKTOP}px, calc(100% - ${theme.spacing(8)}));
will-change: transform, opacity;
will-change: top, opacity;
`;
type SceneProps = {
@@ -65,24 +62,18 @@ type SceneProps = {
export function Scene({ data }: SceneProps) {
const sectionRef = useRef<HTMLElement>(null);
const innerRef = useRef<HTMLDivElement>(null);
const headlineRef = useRef<HTMLDivElement>(null);
const cardRefs = useRef<(HTMLDivElement | null)[]>([]);
return (
<ScrollStage
aria-label="Customer stories"
ref={sectionRef}
style={{ height: `${SCROLL_HEIGHT_VH}vh` }}
>
<ScrollStage aria-label="Customer stories" ref={sectionRef}>
<HelpedSceneScrollLayoutEffect
cardRefs={cardRefs}
cards={data.cards}
headlineRef={headlineRef}
innerRef={innerRef}
sectionRef={sectionRef}
/>
<StickyInner ref={innerRef}>
<HeadlineBlock ref={headlineRef}>
<HeadlineBlock>
<Eyebrow colorScheme="primary" heading={data.eyebrow.heading} />
<Heading as="h2" segments={data.heading} size="xl" weight="light" />
</HeadlineBlock>
@@ -15,14 +15,12 @@ type HelpedSceneScrollLayoutEffectProps = HelpedSceneLayoutRefs & {
export function HelpedSceneScrollLayoutEffect({
cardRefs,
cards,
headlineRef,
innerRef,
sectionRef,
}: HelpedSceneScrollLayoutEffectProps) {
useEffect(() => {
const refs: HelpedSceneLayoutRefs = {
cardRefs,
headlineRef,
innerRef,
sectionRef,
};
@@ -51,7 +49,7 @@ export function HelpedSceneScrollLayoutEffect({
window.cancelAnimationFrame(rafId);
}
};
}, [cardRefs, cards, headlineRef, innerRef, sectionRef]);
}, [cardRefs, cards, innerRef, sectionRef]);
return null;
}
@@ -3,60 +3,36 @@ import type { RefObject } from 'react';
import type { HeadingCardType } from '@/sections/Helped/types/HeadingCard';
import { theme } from '@/theme';
export const CARD_WIDTH_DESKTOP = 443;
const CARD_WIDTH_MOBILE_MAX = 360;
const HORIZONTAL_PAD = 32;
// Scroll progress 0..1 through the tall section
const HEADLINE_FADE_START = 0.76;
const HEADLINE_FADE_END = 0.92;
const HEADLINE_LIFT_PX = 48;
// Each card fades/slides in over the same window, shifted along the scroll
const CARD_WINDOW = 0.2;
const CARD_STAGGER = 0.12;
const CARD_FIRST_START = 0.06;
const ENTER_OFFSET_Y_RATIO = 0.34;
const CARD_WIDTH_DESKTOP = 443;
const CARD_WIDTH_MOBILE = 360;
const PROGRESS_SCALE = 1.25;
const FADE_FRACTION = 0.15;
function clamp01(value: number) {
return Math.min(1, Math.max(0, value));
}
function cardRestPosition(
// Right, left, center for cards 0, 1, 2+
function cardLeft(
index: number,
innerWidth: number,
innerHeight: number,
cardWidth: number,
isDesktop: boolean,
): { left: number; top: number } {
if (isDesktop) {
if (index === 0) {
return {
left: innerWidth - cardWidth - innerWidth * 0.035,
top: innerHeight * 0.08,
};
}
if (index === 1) {
return {
left: innerWidth * 0.03,
top: innerHeight * 0.3,
};
}
return {
left: (innerWidth - cardWidth) / 2,
top: innerHeight * 0.52,
};
): number {
if (!isDesktop) {
return (innerWidth - cardWidth) / 2;
}
return {
left: (innerWidth - cardWidth) / 2,
top: innerHeight * 0.36 + index * 0.06 * innerHeight,
};
if (index === 0) {
return innerWidth - cardWidth - innerWidth * 0.035;
}
if (index === 1) {
return innerWidth * 0.03;
}
return (innerWidth - cardWidth) / 2;
}
export type HelpedSceneLayoutRefs = {
cardRefs: RefObject<(HTMLDivElement | null)[]>;
headlineRef: RefObject<HTMLDivElement | null>;
innerRef: RefObject<HTMLDivElement | null>;
sectionRef: RefObject<HTMLElement | null>;
};
@@ -65,9 +41,8 @@ export function applyHelpedSceneLayout(
refs: HelpedSceneLayoutRefs,
cards: readonly HeadingCardType[],
): void {
const { sectionRef, innerRef, headlineRef, cardRefs } = refs;
const section = sectionRef.current;
const inner = innerRef.current;
const section = refs.sectionRef.current;
const inner = refs.innerRef.current;
if (!section || !inner) {
return;
}
@@ -79,61 +54,43 @@ export function applyHelpedSceneLayout(
`(min-width: ${theme.breakpoints.md}px)`,
).matches;
const rect = section.getBoundingClientRect();
const scrollRange = Math.max(1, section.offsetHeight - window.innerHeight);
const progress = clamp01(-rect.top / scrollRange);
const progress =
clamp01(-section.getBoundingClientRect().top / scrollRange) * PROGRESS_SCALE;
const innerWidth = inner.offsetWidth;
const innerHeight = inner.offsetHeight;
const cardWidth = Math.min(
isDesktop ? CARD_WIDTH_DESKTOP : CARD_WIDTH_MOBILE_MAX,
innerWidth - HORIZONTAL_PAD * 2,
isDesktop ? CARD_WIDTH_DESKTOP : CARD_WIDTH_MOBILE,
innerWidth - 64,
);
if (headlineRef.current) {
const headlineT = clamp01(
(progress - HEADLINE_FADE_START) /
(HEADLINE_FADE_END - HEADLINE_FADE_START),
);
headlineRef.current.style.opacity = String(1 - headlineT);
headlineRef.current.style.transform = `translate3d(0, ${-headlineT * HEADLINE_LIFT_PX}px, 0)`;
}
const enterOffsetY = innerHeight * ENTER_OFFSET_Y_RATIO;
cards.forEach((_, index) => {
const node = cardRefs.current[index];
const node = refs.cardRefs.current[index];
if (!node) {
return;
}
const { left: restLeft, top: restTop } = cardRestPosition(
index,
innerWidth,
innerHeight,
cardWidth,
isDesktop,
);
node.style.width = `${cardWidth}px`;
node.style.zIndex = String(10 + index);
node.style.left = `${cardLeft(index, innerWidth, cardWidth, isDesktop)}px`;
if (reducedMotion) {
node.style.opacity = '1';
node.style.transform = 'none';
node.style.left = `${restLeft}px`;
node.style.top = `${restTop}px`;
node.style.top = `${innerHeight * (0.15 + index * 0.25)}px`;
return;
}
const windowStart = CARD_FIRST_START + index * CARD_STAGGER;
const windowEnd = windowStart + CARD_WINDOW;
const cardT = clamp01(
(progress - windowStart) / (windowEnd - windowStart),
);
// 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);
node.style.left = `${restLeft}px`;
node.style.top = `${restTop + enterOffsetY * (1 - cardT)}px`;
node.style.opacity = String(cardT);
node.style.top = `${innerHeight * (1.1 - travel * 1.4)}px`;
node.style.opacity = String(
Math.min(
clamp01(travel / FADE_FRACTION),
clamp01((1 - travel) / FADE_FRACTION),
),
);
});
}
@@ -33,7 +33,7 @@ export function Flow({ scrollContainerRef, steps }: FlowProps) {
steps={steps}
/>
<RightColumn>
<Visual />
<Visual scrollProgress={scrollProgress} />
</RightColumn>
</>
);
@@ -1,30 +1,61 @@
'use client';
import { Body, Heading } from '@/design-system/components';
import { HOME_STEPPER_HOLD_FRACTIONS } from '@/sections/HomeStepper/utils/home-stepper-lottie-frame-map';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import type { CSSProperties } from 'react';
import type { HomeStepperStepType } from '../../types/HomeStepperStep';
import { ProgressBar } from '../ProgressBar/ProgressBar';
const LeftColumnRoot = styled.div`
display: grid;
grid-template-columns: auto 1fr;
gap: ${theme.spacing(6)};
min-width: 0;
@media (min-width: ${theme.breakpoints.md}px) {
gap: ${theme.spacing(20)};
display: grid;
margin-left: calc(-1 * ${theme.spacing(4)});
}
`;
const StepsColumn = styled.div`
const StickyPanel = styled.div`
display: grid;
gap: ${theme.spacing(6)};
grid-template-columns: auto 1fr;
@media (min-width: ${theme.breakpoints.md}px) {
align-items: center;
align-self: start;
gap: ${theme.spacing(20)};
grid-area: 1 / 1;
height: 100vh;
overflow: hidden;
position: sticky;
top: 0;
}
`;
const SpacerStack = styled.div`
display: none;
@media (min-width: ${theme.breakpoints.md}px) {
display: block;
grid-area: 1 / 1;
}
`;
const StepSpacer = styled.div`
min-height: 100vh;
`;
const StepsContainer = styled.div`
display: grid;
grid-template-columns: 1fr;
min-width: 0;
@media (min-width: ${theme.breakpoints.md}px) {
max-width: 454px;
& > * {
grid-area: 1 / 1;
}
}
`;
@@ -32,18 +63,28 @@ const StepBlock = styled.div`
align-content: center;
display: grid;
grid-template-columns: 1fr;
max-width: 454px;
min-height: 80vh;
min-width: 0;
opacity: 0.3;
row-gap: ${theme.spacing(4)};
@media (min-width: ${theme.breakpoints.md}px) {
min-height: 100vh;
row-gap: ${theme.spacing(6)};
}
transition: opacity 0.3s ease;
&[data-active='true'] {
opacity: 1;
}
@media (min-width: ${theme.breakpoints.md}px) {
min-height: 0;
row-gap: ${theme.spacing(6)};
transition: none;
&,
&[data-active='true'] {
opacity: var(--step-opacity, 0);
transform: var(--step-transform, translateY(40vh));
}
}
`;
export type HomeStepperLeftColumnProps = {
@@ -52,6 +93,36 @@ export type HomeStepperLeftColumnProps = {
steps: HomeStepperStepType[];
};
function computeDesktopStepStyle(
index: number,
activeStepIndex: number,
localProgress: number,
stepCount: number,
): { opacity: number; transform: string } {
const holdMax = HOME_STEPPER_HOLD_FRACTIONS[activeStepIndex] ?? 1;
if (index === activeStepIndex) {
if (index === stepCount - 1 || localProgress <= holdMax) {
return { opacity: 1, transform: 'translateY(0)' };
}
const t = Math.min(1, (localProgress - holdMax) / (1 - holdMax));
return {
opacity: 1 - t,
transform: `translateY(${-40 * t}vh)`,
};
}
if (index === activeStepIndex + 1 && localProgress > holdMax) {
const t = Math.min(1, (localProgress - holdMax) / (1 - holdMax));
return {
opacity: t,
transform: `translateY(${40 * (1 - t)}vh)`,
};
}
return { opacity: 0, transform: 'translateY(40vh)' };
}
export function LeftColumn({
activeStepIndex,
localProgress,
@@ -59,22 +130,44 @@ export function LeftColumn({
}: HomeStepperLeftColumnProps) {
return (
<LeftColumnRoot>
<ProgressBar
activeStepIndex={activeStepIndex}
localProgress={localProgress}
steps={steps}
/>
<StepsColumn>
{steps.map((step, index) => (
<StepBlock
data-active={String(index === activeStepIndex)}
key={index}
>
<Heading segments={step.heading} size="lg" weight="light" />
<Body body={step.body} size="sm" />
</StepBlock>
<StickyPanel>
<ProgressBar
activeStepIndex={activeStepIndex}
localProgress={localProgress}
steps={steps}
/>
<StepsContainer>
{steps.map((step, index) => {
const { opacity, transform } = computeDesktopStepStyle(
index,
activeStepIndex,
localProgress,
steps.length,
);
return (
<StepBlock
data-active={String(index === activeStepIndex)}
key={index}
style={
{
'--step-opacity': opacity,
'--step-transform': transform,
} as CSSProperties
}
>
<Heading segments={step.heading} size="lg" weight="light" />
<Body body={step.body} size="sm" />
</StepBlock>
);
})}
</StepsContainer>
</StickyPanel>
<SpacerStack>
{steps.map((_, index) => (
<StepSpacer key={index} />
))}
</StepsColumn>
</SpacerStack>
</LeftColumnRoot>
);
}
@@ -7,9 +7,10 @@ const StyledRightColumn = styled.div`
width: 100%;
@media (min-width: ${theme.breakpoints.md}px) {
align-self: start;
max-width: 672px;
position: sticky;
top: ${theme.spacing(10)};
top: calc(4.5rem + (100vh - 4.5rem) * 0.5 - 368px);
}
`;
@@ -1,9 +1,10 @@
'use client';
import { scrollProgressToHomeStepperLottieFrame } from '@/sections/HomeStepper/utils/home-stepper-lottie-frame-map';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import Lottie from 'lottie-react';
import { useEffect, useState } from 'react';
import Lottie, { type LottieRefCurrentProps } from 'lottie-react';
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
export const HOME_STEPPER_LOTTIE_SRC = '/images/home/stepper/lottie.json';
@@ -16,17 +17,51 @@ const LottieSlot = styled.div`
width: 100%;
`;
type LottieAnimationData = Record<string, unknown>;
type LottieAnimationData = {
ip?: number;
op?: number;
} & Record<string, unknown>;
export function StepperLottie() {
const [animationData, setAnimationData] = useState<LottieAnimationData | null>(
null,
type StepperLottieProps = {
scrollProgress: number;
};
function lottieTotalFrameCount(animationData: LottieAnimationData): number {
const op = typeof animationData.op === 'number' ? animationData.op : 0;
const ip = typeof animationData.ip === 'number' ? animationData.ip : 0;
const rounded = Math.round(op - ip);
return rounded >= 1 ? rounded : 0;
}
function applyScrollToLottie(
lottieApi: LottieRefCurrentProps | null,
scrollProgress: number,
totalFrames: number,
) {
if (!lottieApi?.animationLoaded || totalFrames <= 0) {
return;
}
const frame = scrollProgressToHomeStepperLottieFrame(
scrollProgress,
totalFrames,
);
lottieApi.goToAndStop(frame, true);
}
export function StepperLottie({ scrollProgress }: StepperLottieProps) {
const [animationData, setAnimationData] =
useState<LottieAnimationData | null>(null);
const [totalFrames, setTotalFrames] = useState(0);
const lottieRef = useRef<LottieRefCurrentProps | null>(null);
const scrollProgressRef = useRef(scrollProgress);
scrollProgressRef.current = scrollProgress;
const totalFramesRef = useRef(totalFrames);
totalFramesRef.current = totalFrames;
useEffect(() => {
let cancelled = false;
const controller = new AbortController();
fetch(HOME_STEPPER_LOTTIE_SRC)
fetch(HOME_STEPPER_LOTTIE_SRC, { signal: controller.signal })
.then((response) => {
if (!response.ok) {
throw new Error(`Lottie fetch failed: ${response.status}`);
@@ -34,21 +69,28 @@ export function StepperLottie() {
return response.json() as Promise<LottieAnimationData>;
})
.then((data) => {
if (!cancelled) {
setAnimationData(data);
}
setAnimationData(data);
setTotalFrames(lottieTotalFrameCount(data));
})
.catch(() => {
if (!cancelled) {
setAnimationData(null);
.catch((error: unknown) => {
if (error instanceof Error && error.name === 'AbortError') {
return;
}
setAnimationData(null);
});
return () => {
cancelled = true;
controller.abort();
};
}, []);
useLayoutEffect(() => {
if (!animationData) {
return;
}
applyScrollToLottie(lottieRef.current, scrollProgress, totalFrames);
}, [animationData, scrollProgress, totalFrames]);
if (!animationData) {
return <LottieSlot aria-hidden />;
}
@@ -57,8 +99,18 @@ export function StepperLottie() {
<LottieSlot aria-hidden>
<Lottie
animationData={animationData}
loop
autoplay={false}
loop={false}
lottieRef={lottieRef}
style={{ height: '100%', maxWidth: '100%', width: '100%' }}
onDOMLoaded={() => {
lottieRef.current?.setSubframe(true);
applyScrollToLottie(
lottieRef.current,
scrollProgressRef.current,
totalFramesRef.current,
);
}}
/>
</LottieSlot>
);
@@ -6,13 +6,17 @@ import { StepperLottie } from './StepperLottie';
const HOME_STEPPER_BACKGROUND = '/images/home/stepper/background.png';
const HOME_STEPPER_SHAPE = '/images/home/stepper/background-shape.png';
export function Visual() {
type VisualProps = {
scrollProgress: number;
};
export function Visual({ scrollProgress }: VisualProps) {
return (
<StepperVisualFrame
backgroundSrc={HOME_STEPPER_BACKGROUND}
shapeSrc={HOME_STEPPER_SHAPE}
>
<StepperLottie />
<StepperLottie scrollProgress={scrollProgress} />
</StepperVisualFrame>
);
}
@@ -0,0 +1,41 @@
const STEP_COUNT = 3;
function clampUnit(value: number): number {
return Math.max(0, Math.min(1, value));
}
// Lottie timeline boundaries (from the animation source):
// Step 1 content: frames 0345, transition 1→2: frames 346415
// Step 2 content: frames 416933, transition 2→3: frames 934980
// Step 3 content: frames 981end
const STEP_1_TRANSITION_END = 415;
const STEP_2_TRANSITION_END = 980;
// Per-step hold fraction: how much of each step's scroll drives Lottie-only
// (left column stays fixed); the remainder drives the content transition.
export const HOME_STEPPER_HOLD_FRACTIONS = [
345 / STEP_1_TRANSITION_END,
(933 - STEP_1_TRANSITION_END) / (STEP_2_TRANSITION_END - STEP_1_TRANSITION_END),
1,
] as const;
export function scrollProgressToHomeStepperLottieFrame(
scrollProgress: number,
totalFrames: number,
): number {
const lastIndex = Math.max(0, totalFrames - 1);
const clamped = clampUnit(scrollProgress);
const stepFloat = clamped * STEP_COUNT;
const stepIndex = Math.min(STEP_COUNT - 1, Math.floor(stepFloat));
const localProgress = stepFloat - stepIndex;
const rangeStart =
stepIndex === 0 ? 0 : stepIndex === 1 ? STEP_1_TRANSITION_END : STEP_2_TRANSITION_END;
const rangeEnd =
stepIndex === 0 ? STEP_1_TRANSITION_END : stepIndex === 1 ? STEP_2_TRANSITION_END : lastIndex;
const frame = rangeStart + localProgress * (rangeEnd - rangeStart);
return Math.max(0, Math.min(lastIndex, frame));
}
@@ -16,6 +16,7 @@ import { MenuDrawer } from '../Drawer/Drawer';
const StyledSection = styled.section`
backdrop-filter: blur(10px);
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.06);
min-width: 0;
position: sticky;
top: 0;
@@ -24,7 +25,8 @@ const StyledSection = styled.section`
`;
const StyledContainer = styled(Container)`
padding-top: ${theme.spacing(4)};
padding-top: ${theme.spacing(2)};
padding-bottom: ${theme.spacing(2)};
position: relative;
z-index: 100;
`;
@@ -1,4 +1,9 @@
import { LinkButton } from '@/design-system/components';
'use client';
import {
BaseButton,
buttonBaseStyles,
} from '@/design-system/components/Button/BaseButton';
import { CheckIcon } from '@/icons';
import type {
PlanTableCellType,
@@ -8,6 +13,7 @@ import type {
} from '@/sections/PlanTable/types';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import { useState } from 'react';
import { CalculatorEmbed } from '../CalculatorEmbed/CalculatorEmbed';
const TableScope = styled.div`
@@ -15,7 +21,6 @@ const TableScope = styled.div`
color: ${theme.colors.secondary.text[100]};
display: flex;
flex-direction: column;
gap: ${theme.spacing(10)};
width: 100%;
`;
@@ -94,12 +99,36 @@ const CategoryTitle = styled.span`
width: 100%;
`;
const CollapsibleWrapper = styled.div`
display: grid;
grid-template-rows: 0fr;
opacity: 0;
transition:
grid-template-rows 0.4s ease,
opacity 0.4s ease;
width: 100%;
&[data-expanded='true'] {
grid-template-rows: 1fr;
opacity: 1;
}
`;
const CollapsibleInner = styled.div`
overflow: hidden;
`;
const CtaRow = styled.div`
display: flex;
justify-content: center;
margin-top: ${theme.spacing(10)};
width: 100%;
`;
const ToggleButton = styled.button`
${buttonBaseStyles}
`;
type CellValueProps = {
cell: PlanTableCellType;
};
@@ -162,6 +191,44 @@ type ContentProps = {
};
export function Content({ data }: ContentProps) {
const [expanded, setExpanded] = useState(false);
const initialRows = data.rows.slice(0, data.initialVisibleRowCount);
const extraRows = data.rows.slice(data.initialVisibleRowCount);
const hasMoreRows = extraRows.length > 0;
const toggleLabel = expanded
? data.seeMoreFeaturesCta.collapseLabel
: data.seeMoreFeaturesCta.expandLabel;
const mapRows = (rows: PlanTableDataType['rows'], startIndex: number) =>
rows.map((row, index) => {
const rowIndex = startIndex + index;
if (row.type === 'category') {
return (
<CategoryRow key={`${row.title}-${rowIndex}`} title={row.title} />
);
}
if (row.type === 'calculator') {
return (
<CalculatorEmbed
calculator={row.calculator}
key={`calculator-${rowIndex}`}
/>
);
}
return (
<FeatureRow
key={`${row.featureLabel}-${rowIndex}`}
row={row}
tierColumns={data.tierColumns}
/>
);
});
return (
<TableScope>
<GridRow>
@@ -171,43 +238,27 @@ export function Content({ data }: ContentProps) {
))}
</GridRow>
{data.rows.map((row, rowIndex) => {
if (row.type === 'category') {
return (
<CategoryRow
key={`${row.title}-${rowIndex}`}
title={row.title}
{mapRows(initialRows, 0)}
{hasMoreRows && (
<CollapsibleWrapper data-expanded={String(expanded)}>
<CollapsibleInner>
{mapRows(extraRows, data.initialVisibleRowCount)}
</CollapsibleInner>
</CollapsibleWrapper>
)}
{hasMoreRows && (
<CtaRow>
<ToggleButton onClick={() => setExpanded((prev) => !prev)}>
<BaseButton
color="primary"
label={toggleLabel}
variant="outlined"
/>
);
}
if (row.type === 'calculator') {
return (
<CalculatorEmbed
calculator={row.calculator}
key={`calculator-${rowIndex}`}
/>
);
}
return (
<FeatureRow
key={`${row.featureLabel}-${rowIndex}`}
row={row}
tierColumns={data.tierColumns}
/>
);
})}
<CtaRow>
<LinkButton
color="secondary"
href={data.seeMoreFeaturesCta.href}
label={data.seeMoreFeaturesCta.label}
type="link"
variant="outlined"
/>
</CtaRow>
</ToggleButton>
</CtaRow>
)}
</TableScope>
);
}
@@ -69,10 +69,11 @@ export type PlanTableBodyRowDataType =
export type PlanTableDataType = {
featureColumnLabel: string;
initialVisibleRowCount: number;
rows: PlanTableBodyRowDataType[];
seeMoreFeaturesCta: {
href: string;
label: string;
collapseLabel: string;
expandLabel: string;
};
tierColumns: PlanTableTierColumnType[];
};
@@ -21,6 +21,10 @@ const ContentRoot = styled.div`
align-self: stretch;
gap: ${theme.spacing(20)};
margin-left: calc(-1 * ${theme.spacing(4)});
position: sticky;
top: 0;
height: 100vh;
align-items: center;
}
`;
@@ -33,8 +37,6 @@ const StepsColumn = styled.div`
@media (min-width: ${theme.breakpoints.md}px) {
height: max-content;
max-width: 556px;
position: sticky;
top: ${theme.spacing(20)};
}
`;
@@ -13,9 +13,10 @@ const VisualColumn = styled.div`
width: 100%;
@media (min-width: ${theme.breakpoints.md}px) {
align-self: start;
max-width: 672px;
position: sticky;
top: ${theme.spacing(10)};
top: calc(4.5rem + (100vh - 4.5rem) * 0.5 - 368px);
}
`;
@@ -22,6 +22,10 @@ const CopyColumn = styled.div`
gap: ${theme.spacing(2)};
max-width: 400px;
min-width: 0;
@media (min-width: ${theme.breakpoints.md}px) {
align-self: center;
}
`;
const RightColumn = styled.div`
@@ -7,7 +7,7 @@ import type {
} from '@/sections/Salesforce/types';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import { useEffect, useRef, useState } from 'react';
import { useRef } from 'react';
const formatPriceAmount = (amount: number) =>
`$${new Intl.NumberFormat('en-US').format(amount)}`;
@@ -54,10 +54,6 @@ const calculatePriceAmounts = (
};
const PANEL_BACKGROUND = '#c9c9c9';
const CARD_STICKY_TOP_OFFSET_PX = 64;
const CARD_STICKY_BOTTOM_OFFSET_PX = 340;
type StickyHeaderMode = 'absolute' | 'fixed';
const Panel = styled.div`
background-color: ${PANEL_BACKGROUND};
@@ -69,17 +65,7 @@ const Panel = styled.div`
width: 100%;
`;
const StickyHeaderSpacer = styled.div<{ $height: number }>`
height: ${({ $height }) => $height}px;
width: 100%;
`;
const StickyHeader = styled.div<{
$absoluteTop: number;
$left: number;
$mode: StickyHeaderMode;
$width: number;
}>`
const PricingHeader = styled.div`
background-color: ${PANEL_BACKGROUND};
box-shadow:
inset 1px 0 0 0 #dfdfdf,
@@ -88,11 +74,8 @@ const StickyHeader = styled.div<{
inset -2px 0 0 0 #808080,
inset 0 1px 0 0 #dfdfdf,
inset 0 2px 0 0 #ffffff;
left: ${({ $left, $mode }) => ($mode === 'fixed' ? `${$left}px` : '0')};
position: ${({ $mode }) => ($mode === 'fixed' ? 'fixed' : 'absolute')};
top: ${({ $absoluteTop, $mode }) =>
$mode === 'fixed' ? `${CARD_STICKY_TOP_OFFSET_PX}px` : `${$absoluteTop}px`};
width: ${({ $mode, $width }) => ($mode === 'fixed' ? `${$width}px` : '100%')};
position: relative;
width: 100%;
z-index: 20;
`;
@@ -437,139 +420,20 @@ export type PricingWindowProps = {
pricing: SalesforcePricingPanelType;
};
type StickyHeaderState = {
absoluteTop: number;
height: number;
left: number;
mode: StickyHeaderMode;
width: number;
};
export function PricingWindow({
checkedIds,
onAddonToggle,
onClose,
pricing,
}: PricingWindowProps) {
const panelRef = useRef<HTMLDivElement | null>(null);
const stickyHeaderRef = useRef<HTMLDivElement | null>(null);
const addonAnchorRefs = useRef<Record<string, HTMLLabelElement | null>>({});
const [stickyHeaderState, setStickyHeaderState] = useState<StickyHeaderState>(
{
absoluteTop: 0,
height: 0,
left: 0,
mode: 'absolute',
width: 0,
},
);
const { fixedPriceAmount, perSeatPriceAmount, totalPriceAmount } =
calculatePriceAmounts(pricing, checkedIds);
useEffect(() => {
let frameId = 0;
const updateStickyHeaderState = () => {
frameId = 0;
const panel = panelRef.current;
const stickyHeader = stickyHeaderRef.current;
if (!panel || !stickyHeader) {
return;
}
const panelRect = panel.getBoundingClientRect();
const stickyHeight = stickyHeader.offsetHeight;
const panelHeight = panel.offsetHeight;
const panelTop = window.scrollY + panelRect.top;
const maxAbsoluteTop = Math.max(
0,
panelHeight - stickyHeight - CARD_STICKY_BOTTOM_OFFSET_PX,
);
const fixedEndScrollY =
panelTop +
panelHeight -
stickyHeight -
CARD_STICKY_TOP_OFFSET_PX -
CARD_STICKY_BOTTOM_OFFSET_PX;
const nextState: StickyHeaderState =
window.scrollY >= panelTop - CARD_STICKY_TOP_OFFSET_PX &&
window.scrollY < fixedEndScrollY
? {
absoluteTop: 0,
height: stickyHeight,
left: panelRect.left,
mode: 'fixed',
width: panelRect.width,
}
: {
absoluteTop:
window.scrollY >= fixedEndScrollY ? maxAbsoluteTop : 0,
height: stickyHeight,
left: 0,
mode: 'absolute',
width: panelRect.width,
};
setStickyHeaderState((previous) =>
previous.absoluteTop === nextState.absoluteTop &&
previous.height === nextState.height &&
previous.left === nextState.left &&
previous.mode === nextState.mode &&
previous.width === nextState.width
? previous
: nextState,
);
};
const requestStickyHeaderUpdate = () => {
if (frameId !== 0) {
return;
}
frameId = window.requestAnimationFrame(updateStickyHeaderState);
};
requestStickyHeaderUpdate();
window.addEventListener('resize', requestStickyHeaderUpdate);
window.addEventListener('scroll', requestStickyHeaderUpdate, {
passive: true,
});
const resizeObserver = new ResizeObserver(requestStickyHeaderUpdate);
if (panelRef.current) {
resizeObserver.observe(panelRef.current);
}
if (stickyHeaderRef.current) {
resizeObserver.observe(stickyHeaderRef.current);
}
return () => {
if (frameId !== 0) {
window.cancelAnimationFrame(frameId);
}
resizeObserver.disconnect();
window.removeEventListener('resize', requestStickyHeaderUpdate);
window.removeEventListener('scroll', requestStickyHeaderUpdate);
};
}, [fixedPriceAmount]);
return (
<Panel ref={panelRef}>
<Panel>
<WindowChrome aria-hidden="true" />
<StickyHeaderSpacer $height={stickyHeaderState.height} />
<StickyHeader
$absoluteTop={stickyHeaderState.absoluteTop}
$left={stickyHeaderState.left}
$mode={stickyHeaderState.mode}
$width={stickyHeaderState.width}
ref={stickyHeaderRef}
>
<PricingHeader>
<TitleBar>
<TitleBarText>{pricing.windowTitle}</TitleBarText>
<TitleBarActions>
@@ -621,7 +485,7 @@ export function PricingWindow({
<Separator aria-hidden="true" />
</SummaryInner>
</SummaryPad>
</StickyHeader>
</PricingHeader>
<ContentPad>
<Inner>
<SectionLabel>{pricing.featureSectionHeading}</SectionLabel>
@@ -1,6 +1,10 @@
'use client';
import { ThreeCardsScrollLayoutEffect } from '@/sections/ThreeCards/effect-components/ThreeCardsScrollLayoutEffect';
import { ThreeCardsFeatureCardType } from '@/sections/ThreeCards/types';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import { useRef } from 'react';
import { FeatureCard } from '../FeatureCard/FeatureCard';
const FeatureCardsGrid = styled.div`
@@ -15,13 +19,32 @@ const FeatureCardsGrid = styled.div`
}
`;
const CardSlot = styled.div`
will-change: transform, opacity;
`;
type FeatureCardsProps = { featureCards: ThreeCardsFeatureCardType[] };
export function FeatureCards({ featureCards }: FeatureCardsProps) {
const gridRef = useRef<HTMLDivElement>(null);
const cardRefs = useRef<(HTMLDivElement | null)[]>([]);
return (
<FeatureCardsGrid>
<FeatureCardsGrid ref={gridRef}>
<ThreeCardsScrollLayoutEffect
cardCount={featureCards.length}
cardRefs={cardRefs}
gridRef={gridRef}
/>
{featureCards.map((featureCard, index) => (
<FeatureCard key={index} featureCard={featureCard} />
<CardSlot
key={index}
ref={(element) => {
cardRefs.current[index] = element;
}}
>
<FeatureCard featureCard={featureCard} />
</CardSlot>
))}
</FeatureCardsGrid>
);
@@ -1,7 +1,6 @@
'use client';
import { Body, Heading, IconButton } from '@/design-system/components';
import { ArrowRightIcon } from '@/icons';
import { Body, Heading } from '@/design-system/components';
import { THREE_CARDS_ILLUSTRATIONS } from '@/illustrations';
import type { ThreeCardsIllustrationCardType } from '@/sections/ThreeCards/types';
import { theme } from '@/theme';
@@ -40,10 +39,9 @@ const CardEmbed = styled.div`
`;
const CardFooter = styled.footer`
display: grid;
grid-template-columns: auto auto auto 1fr;
align-items: start;
column-gap: ${theme.spacing(2)};
display: flex;
align-items: center;
gap: ${theme.spacing(2)};
`;
const AttributionPipe = styled.span`
@@ -53,10 +51,6 @@ const AttributionPipe = styled.span`
border-left: 1px solid ${theme.colors.primary.border[20]};
`;
const FooterTrailingAction = styled.div`
justify-self: end;
`;
const CardBodyCell = styled.div`
align-self: start;
min-height: 0;
@@ -111,17 +105,6 @@ export function IllustrationCard({
size="xs"
weight="regular"
/>
<FooterTrailingAction>
<IconButton
icon={ArrowRightIcon}
ariaLabel="Learn more"
borderColor={theme.colors.primary.border[20]}
iconFillColor="transparent"
iconSize={24}
iconStrokeColor={theme.colors.primary.text[80]}
size={48}
/>
</FooterTrailingAction>
</CardFooter>
)}
</IllustrationCardContainer>
@@ -1,6 +1,10 @@
'use client';
import { ThreeCardsScrollLayoutEffect } from '@/sections/ThreeCards/effect-components/ThreeCardsScrollLayoutEffect';
import { ThreeCardsIllustrationCardType } from '@/sections/ThreeCards/types';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import { useRef } from 'react';
import { IllustrationCard } from '../IllustrationCard/IllustrationCard';
const IllustrationCardsGrid = styled.div`
@@ -15,6 +19,10 @@ const IllustrationCardsGrid = styled.div`
}
`;
const CardSlot = styled.div`
will-change: transform, opacity;
`;
type IllustrationCardsProps = {
illustrationCards: ThreeCardsIllustrationCardType[];
variant?: 'shaped' | 'simple';
@@ -24,14 +32,28 @@ export function IllustrationCards({
illustrationCards,
variant = 'shaped',
}: IllustrationCardsProps) {
const gridRef = useRef<HTMLDivElement>(null);
const cardRefs = useRef<(HTMLDivElement | null)[]>([]);
return (
<IllustrationCardsGrid>
<IllustrationCardsGrid ref={gridRef}>
<ThreeCardsScrollLayoutEffect
cardCount={illustrationCards.length}
cardRefs={cardRefs}
gridRef={gridRef}
/>
{illustrationCards.map((illustrationCard, index) => (
<IllustrationCard
<CardSlot
key={index}
variant={variant}
illustrationCard={illustrationCard}
/>
ref={(element) => {
cardRefs.current[index] = element;
}}
>
<IllustrationCard
variant={variant}
illustrationCard={illustrationCard}
/>
</CardSlot>
))}
</IllustrationCardsGrid>
);
@@ -0,0 +1,49 @@
'use client';
import { useEffect } from 'react';
import {
applyThreeCardsScrollLayout,
type ThreeCardsScrollLayoutRefs,
} from '@/sections/ThreeCards/utils/three-cards-scroll-layout';
type ThreeCardsScrollLayoutEffectProps = ThreeCardsScrollLayoutRefs & {
cardCount: number;
};
export function ThreeCardsScrollLayoutEffect({
cardCount,
cardRefs,
gridRef,
}: ThreeCardsScrollLayoutEffectProps) {
useEffect(() => {
const refs: ThreeCardsScrollLayoutRefs = { cardRefs, gridRef };
let rafId: number | null = null;
const flushLayout = () => {
rafId = null;
applyThreeCardsScrollLayout(refs, cardCount);
};
const scheduleLayout = () => {
if (rafId !== null) {
return;
}
rafId = window.requestAnimationFrame(flushLayout);
};
applyThreeCardsScrollLayout(refs, cardCount);
window.addEventListener('scroll', scheduleLayout, { passive: true });
window.addEventListener('resize', scheduleLayout);
return () => {
window.removeEventListener('scroll', scheduleLayout);
window.removeEventListener('resize', scheduleLayout);
if (rafId !== null) {
window.cancelAnimationFrame(rafId);
}
};
}, [cardCount, cardRefs, gridRef]);
return null;
}
@@ -0,0 +1,69 @@
import type { RefObject } from 'react';
import { theme } from '@/theme';
function clamp01(value: number) {
return Math.min(1, Math.max(0, value));
}
function easeOutQuint(t: number) {
return 1 - Math.pow(1 - t, 5);
}
export type ThreeCardsScrollLayoutRefs = {
cardRefs: RefObject<(HTMLDivElement | null)[]>;
gridRef: RefObject<HTMLDivElement | null>;
};
const STAGGER = 0.25;
export function applyThreeCardsScrollLayout(
refs: ThreeCardsScrollLayoutRefs,
cardCount: number,
): void {
const grid = refs.gridRef.current;
if (!grid) {
return;
}
const isDesktop = window.matchMedia(
`(min-width: ${theme.breakpoints.md}px)`,
).matches;
if (!isDesktop) {
for (let index = 0; index < cardCount; index++) {
const node = refs.cardRefs.current[index];
if (!node) {
continue;
}
node.style.opacity = '1';
node.style.transform = 'none';
}
return;
}
const rect = grid.getBoundingClientRect();
const viewportHeight = window.innerHeight;
const startEdge = viewportHeight;
const endEdge = viewportHeight * 0.2;
const progress = clamp01((startEdge - rect.top) / (startEdge - endEdge));
for (let index = 0; index < cardCount; index++) {
const node = refs.cardRefs.current[index];
if (!node) {
continue;
}
const delay = index * STAGGER;
const localProgress = clamp01((progress - delay) / (1 - delay));
const eased = easeOutQuint(localProgress);
const translateY = (1 - eased) * 200;
const scale = 0.88 + eased * 0.12;
const opacity = clamp01(localProgress / 0.4);
node.style.opacity = String(opacity);
node.style.transform = `translateY(${translateY}px) scale(${scale})`;
}
}
@@ -17,6 +17,10 @@ const ContentRoot = styled.div`
align-self: stretch;
gap: ${theme.spacing(20)};
margin-left: calc(-1 * ${theme.spacing(4)});
position: sticky;
top: 0;
height: 100vh;
align-items: center;
}
`;
@@ -29,8 +33,6 @@ const StepsColumn = styled.div`
@media (min-width: ${theme.breakpoints.md}px) {
height: max-content;
max-width: 556px;
position: sticky;
top: calc(50vh - 150px);
}
`;
@@ -3,10 +3,24 @@
import { IllustrationMount } from '@/illustrations';
import { SyncScrollProgressFromContainerEffect } from '@/sections/WhyTwentyStepper/effect-components/SyncScrollProgressFromContainerEffect';
import type { WhyTwentyStepperDataType } from '@/sections/WhyTwentyStepper/types';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
import { useRef, useState } from 'react';
import { Content } from '../Content/Content';
import { Root } from '../Root/Root';
const IllustrationColumn = styled.div`
min-width: 0;
width: 100%;
@media (min-width: ${theme.breakpoints.md}px) {
align-self: start;
max-width: 672px;
position: sticky;
top: calc(4.5rem + (100vh - 4.5rem) * 0.5 - 368px);
}
`;
type FlowProps = WhyTwentyStepperDataType;
export function Flow({ body, heading, illustration }: FlowProps) {
@@ -32,7 +46,9 @@ export function Flow({ body, heading, illustration }: FlowProps) {
heading={heading}
localProgress={localProgress}
/>
<IllustrationMount illustration={illustration} />
<IllustrationColumn>
<IllustrationMount illustration={illustration} />
</IllustrationColumn>
</Root>
);
}