Compare commits

...
Author SHA1 Message Date
Abdullah 9483104ee4 feat(website): add sliding hero tabs and improve mobile nav color sync
Introduce a sliding tab indicator for the product AI hero and align the
sticky nav transition on mobile with the intro/AI boundary using measured
nav height, earlier lead offset, and blur-free solid backgrounds.
2026-05-20 04:58:57 +05:00
Abdullah e37517dc3d fix(website): polish product hero opacity sync and hydration
Align dark overlay and nav opacity with the AI panel when fully in view,
use stable tab ids and remove Edit icon clipPath useId to fix SSR mismatches,
and stabilize menu scroll effect dependencies.
2026-05-20 04:22:02 +05:00
Abdullah a4161833d0 feat(website): sync product nav with hero scroll color transition
Drive the product page navbar from hero scroll progress with opacity-based
light-to-dark transitions, including fade back to light when leaving the AI section.
2026-05-20 04:09:13 +05:00
Abdullah ac03df1f5f feat(website): add scroll-driven product hero with stacked mobile layout
Replace the static product hero with a unified intro-to-AI scroll transition on
desktop and a stacked mobile layout, including taller preview sizing and updated
AI heading copy.
2026-05-20 04:09:08 +05:00
12 changed files with 1118 additions and 255 deletions
@@ -25,7 +25,8 @@ import { Demo } from '@/sections/Demo';
import { Faq, FAQ_QUESTIONS } from '@/sections/Faq';
import { Feature } from '@/sections/Feature';
import { Hero } from '@/sections/Hero';
import { Menu, MENU_DATA } from '@/sections/Menu';
import { ProductHeroMenuSync } from '@/sections/Hero/components/ProductVisual/product-hero-menu-sync';
import { MENU_DATA } from '@/sections/Menu';
import {
ProductStepper,
type ProductStepperStepType,
@@ -35,7 +36,6 @@ import { LayoutVisual } from '@/sections/ProductStepper/visuals/LayoutVisual';
import { WorkflowVisual } from '@/sections/ProductStepper/visuals/WorkflowVisual';
import { ThreeCards } from '@/sections/ThreeCards';
import { TRUSTED_BY_LOGOS, TrustedBy } from '@/sections/TrustedBy';
import { theme } from '@/theme';
import { msg } from '@lingui/core/macro';
import type { AppLocale } from 'twenty-shared/translations';
@@ -90,58 +90,51 @@ export default async function ProductPage({ params }: ProductPageProps) {
i18n.locale as AppLocale,
)}
/>
<Menu.Root
backgroundColor={theme.colors.primary.background[100]}
scheme="primary"
<ProductHeroMenuSync
navItems={MENU_DATA.navItems}
socialLinks={menuSocialLinks}
>
<Menu.Logo scheme="primary" />
<Menu.Nav scheme="primary" navItems={MENU_DATA.navItems} />
<Menu.Social scheme="primary" socialLinks={menuSocialLinks} />
<Menu.Cta scheme="primary" />
</Menu.Root>
<Hero.HeroVisualScroll
aiBody={i18n._(
msg`Ask questions, automate tasks, and get insights. All powered by AI that understands your data.`,
)}
aiHeading={
<Heading size="lg" weight="light">
<HeadingPart fontFamily="serif">
{i18n._(msg`AI that actually`)}
</HeadingPart>
<br />
<HeadingPart fontFamily="serif">
{i18n._(msg`helps you`)}
</HeadingPart>{' '}
<HeadingPart fontFamily="sans">
{i18n._(msg`work faster`)}
</HeadingPart>
</Heading>
}
ctaHref="https://app.twenty.com/welcome"
ctaLabel={i18n._(msg`Get started`)}
introBody={i18n._(
msg`Track relationships, manage pipelines, and take action quickly with a CRM that feels intuitive from day one.`,
)}
introHeading={
<Heading size="lg" weight="light">
<HeadingPart fontFamily="serif">
{i18n._(msg`A CRM for teams`)}
</HeadingPart>
<br />
<HeadingPart fontFamily="serif">
{i18n._(msg`that`)}
</HeadingPart>{' '}
<HeadingPart fontFamily="sans">
{i18n._(msg`move fast`)}
</HeadingPart>
</Heading>
}
tabs={AI_HERO_TABS}
visual={APP_PREVIEW_DATA.visual}
/>
<Hero.HeroVisualScroll
aiBody={i18n._(
msg`Ask questions, automate tasks, and get insights. All powered by AI that understands your data.`,
)}
aiHeading={
<Heading size="lg" weight="light">
<HeadingPart fontFamily="serif">
{i18n._(msg`...with AI that actually`)}
</HeadingPart>
<br />
<HeadingPart fontFamily="serif">
{i18n._(msg`helps you`)}
</HeadingPart>{' '}
<HeadingPart fontFamily="sans">
{i18n._(msg`work faster`)}
</HeadingPart>
</Heading>
}
ctaHref="https://app.twenty.com/welcome"
ctaLabel={i18n._(msg`Get started`)}
introBody={i18n._(
msg`Track relationships, manage pipelines, and take action quickly with a CRM that feels intuitive from day one.`,
)}
introHeading={
<Heading size="lg" weight="light">
<HeadingPart fontFamily="serif">
{i18n._(msg`A CRM for teams`)}
</HeadingPart>
<br />
<HeadingPart fontFamily="serif">
{i18n._(msg`that`)}
</HeadingPart>{' '}
<HeadingPart fontFamily="sans">
{i18n._(msg`move fast`)}
</HeadingPart>
</Heading>
}
tabs={AI_HERO_TABS}
visual={APP_PREVIEW_DATA.visual}
/>
</ProductHeroMenuSync>
<TrustedBy.Root
separator={i18n._(msg`trusted by`)}
@@ -1,12 +1,6 @@
'use client';
import { useId } from 'react';
type EditIconProps = { size: number; color: string; strokeWidth?: number };
export function EditIcon({ size, color, strokeWidth = 2 }: EditIconProps) {
const clipPathId = useId().replace(/:/g, '');
return (
<svg
width={size}
@@ -16,19 +10,10 @@ export function EditIcon({ size, color, strokeWidth = 2 }: EditIconProps) {
xmlns="http://www.w3.org/2000/svg"
aria-hidden
>
<g
clipPath={`url(#${clipPathId})`}
stroke={color}
strokeWidth={strokeWidth}
>
<g stroke={color} strokeWidth={strokeWidth}>
<path d="M7.604 3.621c-.744.034-1.476.112-2.19.194-.96.11-1.729.88-1.835 1.84-.104.94-.2 1.911-.2 2.904s.096 1.963.2 2.904c.106.96.875 1.729 1.835 1.839.946.109 1.922.211 2.919.211s1.973-.102 2.92-.211a2.094 2.094 0 0 0 1.834-1.839 35 35 0 0 0 .178-2.032" />
<path d="M11.916 2.61 8.847 6.09a1.74 1.74 0 0 0-.394.791L8.087 8.6c-.074.349.29.68.63.572l1.724-.542c.287-.09.541-.247.743-.46l3.171-3.336c.578-.608.477-1.625-.222-2.243-.683-.604-1.676-.596-2.217.018Z" />
</g>
<defs>
<clipPath id={clipPathId}>
<path fill="#fff" d="M0 0h16v16H0z" />
</clipPath>
</defs>
</svg>
);
}
@@ -140,12 +140,7 @@ export const AppWindow = ({ children }: AppWindowProps) => {
const parentRect = parent.getBoundingClientRect();
if (parentRect.width < MOBILE_PARENT_BREAKPOINT) {
const mobileWidth = parentRect.width;
const mobileHeight = Math.min(
parentRect.height,
mobileWidth / INITIAL_ASPECT_RATIO,
);
setSize({ width: mobileWidth, height: mobileHeight });
setSize({ width: parentRect.width, height: parentRect.height });
setPosition({ left: 0, top: 0 });
return;
}
@@ -1,16 +1,30 @@
'use client';
import { type ReactNode, useId, useRef, useState } from 'react';
import {
type ReactNode,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from 'react';
import { Container, LinkButton } from '@/design-system/components';
import type { AppPreviewConfig } from '@/sections/AppPreview';
import { TabButtons } from '@/sections/Tabs/components/TabButtons';
import type { TabType } from '@/sections/Tabs/types';
import { theme } from '@/theme';
import { css } from '@linaria/core';
import { styled } from '@linaria/react';
import NextImage from 'next/image';
import {
getHeroMenuColorMix,
getHeroScrollColors,
getHeroScrollMotion,
} from './hero-scroll-colors';
import { TabButtons } from '@/sections/Tabs/components/TabButtons';
import { useProductHeroMenuSync } from './product-hero-menu-sync';
import { PRODUCT_HERO_AI_TABS_ID } from './product-hero-tabs-id';
import { ProductVisual } from './ProductVisual';
import { useHeroScrollProgress } from './use-hero-scroll-progress';
@@ -37,47 +51,50 @@ const ScrollTrack = styled.section`
`;
const StickyFrame = styled.div`
align-items: center;
background-color: #ffffff;
display: flex;
flex-direction: column;
justify-content: flex-start;
overflow: hidden;
padding-bottom: ${theme.spacing(6)};
padding-top: ${theme.spacing(7.5)};
transition: background-color 0.6s ease;
position: relative;
width: 100%;
&[data-phase='0'] {
background-color: var(--color-white, #ffffff);
}
&[data-phase='1'] {
background-color: var(--color-black, #141414);
}
@media (min-width: ${theme.breakpoints.md}px) {
height: calc(100vh - ${NAV_HEIGHT}px);
padding-bottom: 0;
padding-top: ${theme.spacing(12)};
overflow: hidden;
position: sticky;
top: ${NAV_HEIGHT}px;
}
`;
const DarkColorOverlay = styled.div`
background-color: #141414;
inset: 0;
pointer-events: none;
position: absolute;
z-index: 0;
`;
const PatternOverlay = styled.div`
bottom: 0;
height: 575px;
left: 50%;
opacity: 0;
pointer-events: none;
position: absolute;
transform: translateX(-50%);
transition: opacity 0.6s ease;
width: 100%;
z-index: 0;
`;
&[data-visible='true'] {
opacity: 0.4;
const ScrollPatternOverlay = styled(PatternOverlay)`
@media (max-width: ${theme.breakpoints.md - 1}px) {
display: none;
}
`;
const StackedPatternOverlay = styled(PatternOverlay)`
display: none;
@media (max-width: ${theme.breakpoints.md - 1}px) {
display: block;
}
`;
@@ -85,134 +102,123 @@ const patternImageClassName = css`
object-fit: cover;
`;
const StyledContainer = styled(Container)`
const HeroViewport = styled.div`
display: flex;
flex-direction: column;
position: relative;
width: 100%;
z-index: 1;
@media (min-width: ${theme.breakpoints.md}px) {
flex: 1;
min-height: 0;
overflow: hidden;
}
`;
const HeroTrack = styled.div`
display: flex;
flex-direction: column;
flex-shrink: 0;
@media (min-width: ${theme.breakpoints.md}px) {
height: 100%;
will-change: transform;
}
`;
const HeroPanel = styled.div`
display: flex;
flex-direction: column;
flex-shrink: 0;
padding-bottom: ${theme.spacing(6)};
padding-top: ${theme.spacing(7.5)};
@media (min-width: ${theme.breakpoints.md}px) {
height: 100%;
min-height: 0;
padding-bottom: 0;
padding-top: ${theme.spacing(12)};
}
`;
const AiHeroPanel = styled(HeroPanel)`
position: relative;
@media (max-width: ${theme.breakpoints.md - 1}px) {
background-color: #141414;
overflow: hidden;
padding-top: ${theme.spacing(10)};
}
`;
const PanelContainer = styled(Container)`
display: grid;
flex-shrink: 0;
grid-template-columns: minmax(0, 1fr);
justify-items: center;
min-width: 0;
padding-left: ${theme.spacing(4)};
padding-right: ${theme.spacing(4)};
position: relative;
row-gap: ${theme.spacing(6)};
row-gap: ${theme.spacing(3)};
text-align: center;
width: 100%;
z-index: 1;
@media (min-width: ${theme.breakpoints.md}px) {
padding-left: ${theme.spacing(10)};
padding-right: ${theme.spacing(10)};
row-gap: ${theme.spacing(6)};
}
`;
const HeadingSlot = styled.div`
const PanelHeading = styled.div`
max-width: 360px;
min-height: 96px;
width: 100%;
@media (min-width: ${theme.breakpoints.md}px) {
max-width: 672px;
}
position: relative;
transition: color 0.6s ease;
width: 100%;
&[data-phase='0'] {
color: ${theme.colors.primary.text[100]};
}
&[data-phase='1'] {
color: ${theme.colors.secondary.text[100]};
}
`;
const ContentLayer = styled.div`
inset: 0;
opacity: 0;
pointer-events: none;
position: absolute;
transition: opacity 0.6s ease;
&[data-active='true'] {
opacity: 1;
pointer-events: auto;
position: relative;
}
`;
const BodyText = styled.p`
const PanelBody = styled.p`
font-size: ${theme.font.size(4)};
line-height: 1.55;
margin: 0;
max-width: 360px;
transition: color 0.6s ease;
width: 100%;
&[data-phase='0'] {
color: ${theme.colors.primary.text[60]};
}
&[data-phase='1'] {
color: rgba(255, 255, 255, 0.7);
}
@media (min-width: ${theme.breakpoints.md}px) {
max-width: 591px;
}
`;
const HeadingGroup = styled.div`
align-items: center;
display: flex;
flex-direction: column;
gap: ${theme.spacing(3)};
width: 100%;
`;
const ActionSlot = styled.div`
min-height: 48px;
position: relative;
width: 100%;
`;
const CtaLayer = styled.div`
const IntroActions = styled.div`
display: flex;
flex-wrap: wrap;
gap: ${theme.spacing(3)};
inset: 0;
justify-content: center;
opacity: 1;
position: absolute;
transition: opacity 0.4s ease;
&[data-visible='false'] {
opacity: 0;
pointer-events: none;
}
`;
const TabsLayer = styled.div`
opacity: 0;
pointer-events: none;
transition: opacity 0.4s ease 0.2s;
min-height: 48px;
width: 100%;
&[data-visible='true'] {
opacity: 1;
pointer-events: auto;
}
@media (min-width: ${theme.breakpoints.md}px) {
inset: 0;
position: absolute;
}
`;
const VisualWrapper = styled.div`
flex: 1;
min-height: 0;
const TabsActions = styled.div`
min-height: 48px;
width: 100%;
`;
const PanelVisual = styled.div`
overflow: hidden;
width: 100%;
@media (min-width: ${theme.breakpoints.md}px) {
flex: 1;
min-height: 0;
}
`;
const INTRO_PANEL_COLORS = getHeroScrollColors(0, 0);
const AI_PANEL_COLORS = getHeroScrollColors(1, 1);
export function HeroVisualScroll({
aiBody,
aiHeading,
@@ -224,16 +230,138 @@ export function HeroVisualScroll({
visual,
}: HeroScrollProps) {
const trackRef = useRef<HTMLDivElement>(null);
const phase = useHeroScrollProgress(trackRef);
const heroViewportRef = useRef<HTMLDivElement>(null);
const introPanelRef = useRef<HTMLDivElement>(null);
const aiPanelRef = useRef<HTMLDivElement>(null);
const menuSync = useProductHeroMenuSync();
const { colorMix, isScrollDriven, panelMix } =
useHeroScrollProgress(trackRef);
const [activeTab, setActiveTab] = useState(0);
const idPrefix = useId();
const [panelStepPixels, setPanelStepPixels] = useState(0);
const activeScene = phase === 0 ? 0 : activeTab + 1;
const heroColors = getHeroScrollColors(colorMix, panelMix);
const heroMotion = getHeroScrollMotion(panelMix, panelStepPixels);
const introPanelColors = isScrollDriven ? heroColors : INTRO_PANEL_COLORS;
const aiPanelColors = isScrollDriven ? heroColors : AI_PANEL_COLORS;
const isIntroInteractive = !isScrollDriven || panelMix < 0.5;
const isAiInteractive = !isScrollDriven || panelMix >= 0.5;
useLayoutEffect(() => {
const heroViewport = heroViewportRef.current;
if (!heroViewport || !isScrollDriven) {
return;
}
const measureViewport = () => {
setPanelStepPixels(heroViewport.clientHeight);
};
measureViewport();
const resizeObserver = new ResizeObserver(measureViewport);
resizeObserver.observe(heroViewport);
return () => resizeObserver.disconnect();
}, [aiBody, aiHeading, introBody, introHeading, isScrollDriven, tabs]);
const syncMenuColorMix = useCallback(() => {
if (!menuSync) {
return;
}
const track = trackRef.current;
const trackRect = track?.getBoundingClientRect() ?? null;
const aiPanelRect = aiPanelRef.current?.getBoundingClientRect() ?? null;
const introPanelRect =
introPanelRef.current?.getBoundingClientRect() ?? null;
const measuredNavHeight =
menuSync.menuSectionRef.current?.getBoundingClientRect().height ??
NAV_HEIGHT;
menuSync.setMenuDarkOpacity(
getHeroMenuColorMix({
aiPanelRect,
colorMix,
introPanelRect,
isScrollDriven,
navHeight: measuredNavHeight,
panelMix,
trackRect,
viewportHeight: window.innerHeight,
}),
);
}, [colorMix, isScrollDriven, menuSync, panelMix]);
useEffect(() => {
let animationFrameId = 0;
const scheduleSyncMenuColorMix = () => {
cancelAnimationFrame(animationFrameId);
animationFrameId = requestAnimationFrame(syncMenuColorMix);
};
scheduleSyncMenuColorMix();
const introPanel = introPanelRef.current;
const aiPanel = aiPanelRef.current;
const track = trackRef.current;
const menuSection = menuSync?.menuSectionRef.current;
const resizeObserver = new ResizeObserver(scheduleSyncMenuColorMix);
if (introPanel) {
resizeObserver.observe(introPanel);
}
if (aiPanel) {
resizeObserver.observe(aiPanel);
}
if (track) {
resizeObserver.observe(track);
}
if (menuSection) {
resizeObserver.observe(menuSection);
}
const intersectionObserver = new IntersectionObserver(
scheduleSyncMenuColorMix,
{ threshold: [0, 0.25, 0.5, 0.75, 1] },
);
if (introPanel) {
intersectionObserver.observe(introPanel);
}
if (aiPanel) {
intersectionObserver.observe(aiPanel);
}
window.addEventListener('scroll', scheduleSyncMenuColorMix, {
passive: true,
});
window.addEventListener('resize', scheduleSyncMenuColorMix);
return () => {
cancelAnimationFrame(animationFrameId);
resizeObserver.disconnect();
intersectionObserver.disconnect();
window.removeEventListener('scroll', scheduleSyncMenuColorMix);
window.removeEventListener('resize', scheduleSyncMenuColorMix);
};
}, [menuSync, syncMenuColorMix]);
return (
<ScrollTrack ref={trackRef}>
<StickyFrame data-phase={phase}>
<PatternOverlay data-visible={phase === 1 ? 'true' : 'false'}>
<StickyFrame>
{isScrollDriven ? (
<DarkColorOverlay
style={{ opacity: heroColors.darkOverlayOpacity }}
/>
) : null}
<ScrollPatternOverlay style={{ opacity: heroColors.patternOpacity }}>
<NextImage
alt=""
className={patternImageClassName}
@@ -241,44 +369,100 @@ export function HeroVisualScroll({
sizes="100vw"
src="/images/product/tabs/background.webp"
/>
</PatternOverlay>
<StyledContainer>
<HeadingGroup>
<HeadingSlot data-phase={phase}>
<ContentLayer data-active={phase === 0}>
{introHeading}
</ContentLayer>
<ContentLayer data-active={phase === 1}>{aiHeading}</ContentLayer>
</HeadingSlot>
</ScrollPatternOverlay>
<BodyText data-phase={phase}>
{phase === 0 ? introBody : aiBody}
</BodyText>
</HeadingGroup>
<HeroViewport ref={heroViewportRef}>
<HeroTrack
style={
isScrollDriven && panelStepPixels > 0
? {
height: panelStepPixels * 2,
transform: `translate3d(0, ${heroMotion.trackTranslateY}px, 0)`,
}
: undefined
}
>
<HeroPanel
ref={introPanelRef}
style={
isScrollDriven
? {
color: introPanelColors.headingColor,
height: panelStepPixels > 0 ? panelStepPixels : undefined,
}
: { color: introPanelColors.headingColor }
}
>
<PanelContainer>
<PanelHeading>{introHeading}</PanelHeading>
<PanelBody style={{ color: introPanelColors.bodyColor }}>
{introBody}
</PanelBody>
<IntroActions
style={{
pointerEvents: isIntroInteractive ? 'auto' : 'none',
}}
>
<LinkButton
color="secondary"
href={ctaHref}
label={ctaLabel}
variant="contained"
/>
</IntroActions>
</PanelContainer>
<PanelVisual>
<ProductVisual visual={visual} />
</PanelVisual>
</HeroPanel>
<ActionSlot>
<CtaLayer data-visible={phase === 0 ? 'true' : 'false'}>
<LinkButton
color="secondary"
href={ctaHref}
label={ctaLabel}
variant="contained"
/>
</CtaLayer>
<TabsLayer data-visible={phase === 1 ? 'true' : 'false'}>
<TabButtons
activeIndex={activeTab}
idPrefix={idPrefix}
onSelect={setActiveTab}
tabs={tabs}
/>
</TabsLayer>
</ActionSlot>
</StyledContainer>
<VisualWrapper>
<ProductVisual activeScene={activeScene} visual={visual} />
</VisualWrapper>
<AiHeroPanel
ref={aiPanelRef}
style={
isScrollDriven
? {
color: aiPanelColors.headingColor,
height: panelStepPixels > 0 ? panelStepPixels : undefined,
}
: { color: aiPanelColors.headingColor }
}
>
{!isScrollDriven ? (
<StackedPatternOverlay style={{ opacity: 0.4 }}>
<NextImage
alt=""
className={patternImageClassName}
fill
sizes="100vw"
src="/images/product/tabs/background.webp"
/>
</StackedPatternOverlay>
) : null}
<PanelContainer>
<PanelHeading>{aiHeading}</PanelHeading>
<PanelBody style={{ color: aiPanelColors.bodyColor }}>
{aiBody}
</PanelBody>
<TabsActions
style={{
pointerEvents: isAiInteractive ? 'auto' : 'none',
}}
>
<TabButtons
activeIndex={activeTab}
idPrefix={PRODUCT_HERO_AI_TABS_ID}
onSelect={setActiveTab}
selectionStyle="sliding"
tabs={tabs}
/>
</TabsActions>
</PanelContainer>
<PanelVisual>
<ProductVisual activeScene={activeTab + 1} visual={visual} />
</PanelVisual>
</AiHeroPanel>
</HeroTrack>
</HeroViewport>
</StickyFrame>
</ScrollTrack>
);
@@ -28,12 +28,58 @@ const StyledRoot = styled.div`
}
`;
const MOBILE_VISIBLE_HEIGHT_RATIO = 0.7;
const MOBILE_VISUAL_SCALE = 1.14;
const ShellScene = styled.div`
aspect-ratio: 1280 / 832;
margin: 0 auto;
max-height: 740px;
position: relative;
width: 100%;
@media (max-width: ${theme.breakpoints.md - 1}px) {
aspect-ratio: 4 / 5;
max-height: none;
min-height: 300px;
}
`;
const MobileVisualScaler = styled.div`
height: 100%;
overflow: hidden;
position: relative;
width: 100%;
@media (max-width: ${theme.breakpoints.md - 1}px) {
box-sizing: border-box;
padding: ${theme.spacing(4)};
}
`;
const MobileVisualClip = styled.div`
height: 100%;
overflow: hidden;
position: relative;
width: 100%;
`;
const MobileVisualScaleLayer = styled.div`
height: 100%;
position: relative;
width: 100%;
@media (max-width: ${theme.breakpoints.md - 1}px) {
height: calc(
100% / ${MOBILE_VISIBLE_HEIGHT_RATIO} / ${MOBILE_VISUAL_SCALE}
);
left: 50%;
position: absolute;
top: 0;
transform: translateX(-50%) scale(${MOBILE_VISUAL_SCALE});
transform-origin: top center;
width: calc(100% / ${MOBILE_VISUAL_SCALE});
}
`;
const AppLayout = styled.div`
@@ -296,9 +342,12 @@ export function ProductVisual({ activeScene, visual }: ProductVisualProps) {
return (
<StyledRoot>
<ShellScene>
<WindowOrderProvider>
<AppWindow>
<AppLayout>
<MobileVisualScaler>
<MobileVisualClip>
<WindowOrderProvider>
<MobileVisualScaleLayer>
<AppWindow>
<AppLayout>
<AppPreviewSidebar
favoritesNav={visual.favoritesNav}
highlightedItemId={highlightedItemId ?? undefined}
@@ -456,9 +505,12 @@ export function ProductVisual({ activeScene, visual }: ProductVisualProps) {
</AiPanel>
</ContentRow>
</RightColumn>
</AppLayout>
</AppWindow>
</WindowOrderProvider>
</AppLayout>
</AppWindow>
</MobileVisualScaleLayer>
</WindowOrderProvider>
</MobileVisualClip>
</MobileVisualScaler>
</ShellScene>
</StyledRoot>
);
@@ -0,0 +1,271 @@
const LIGHT_BACKGROUND = '#ffffff';
const DARK_BACKGROUND = '#141414';
const LIGHT_HEADING = '#1c1c1c';
const DARK_HEADING = '#ffffff';
const LIGHT_BODY = 'rgba(28, 28, 28, 0.6)';
const DARK_BODY = 'rgba(255, 255, 255, 0.7)';
export const HERO_COLOR_TRANSITION_START = 0.3;
export const HERO_COLOR_TRANSITION_END = 0.75;
export const HERO_PANEL_TRANSITION_START = 0.2;
export const HERO_PANEL_TRANSITION_END = 0.6;
// Start darkening before the intro/AI boundary reaches the nav, then ramp to full dark.
export const HERO_MOBILE_MENU_LEAD_PX = 160;
export const HERO_MOBILE_MENU_TRANSITION_PX = 140;
function mapScrollToRange(
progress: number,
rangeStart: number,
rangeEnd: number,
): number {
if (progress <= rangeStart) {
return 0;
}
if (progress >= rangeEnd) {
return 1;
}
const linearMix = (progress - rangeStart) / (rangeEnd - rangeStart);
return smoothstep(linearMix);
}
export function mapScrollToColorMix(progress: number): number {
return mapScrollToRange(
progress,
HERO_COLOR_TRANSITION_START,
HERO_COLOR_TRANSITION_END,
);
}
export function mapScrollToPanelMix(progress: number): number {
return mapScrollToRange(
progress,
HERO_PANEL_TRANSITION_START,
HERO_PANEL_TRANSITION_END,
);
}
function smoothstep(value: number): number {
return value * value * (3 - 2 * value);
}
// Dark reads as a layer fading in over white — ramps up slowly on scroll.
function mapColorMixToDarkOpacity(colorMix: number): number {
if (colorMix <= 0) {
return 0;
}
if (colorMix >= 1) {
return 1;
}
return Math.pow(colorMix, 1.85);
}
function getDarkOverlayOpacity(colorMix: number, panelMix: number): number {
const fromScroll = mapColorMixToDarkOpacity(colorMix);
const fromPanel = mapColorMixToDarkOpacity(panelMix);
if (panelMix >= 0.5) {
return Math.max(fromScroll, fromPanel);
}
return fromScroll;
}
function parseHexColor(hex: string): [number, number, number] {
const normalized = hex.replace('#', '');
return [
Number.parseInt(normalized.slice(0, 2), 16),
Number.parseInt(normalized.slice(2, 4), 16),
Number.parseInt(normalized.slice(4, 6), 16),
];
}
function parseRgbaColor(rgba: string): [number, number, number, number] {
const match = rgba.match(
/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+))?\s*\)/,
);
if (!match) {
return [0, 0, 0, 1];
}
return [
Number(match[1]),
Number(match[2]),
Number(match[3]),
match[4] !== undefined ? Number(match[4]) : 1,
];
}
function interpolateHex(from: string, to: string, mix: number): string {
const [fromRed, fromGreen, fromBlue] = parseHexColor(from);
const [toRed, toGreen, toBlue] = parseHexColor(to);
const red = Math.round(fromRed + (toRed - fromRed) * mix);
const green = Math.round(fromGreen + (toGreen - fromGreen) * mix);
const blue = Math.round(fromBlue + (toBlue - fromBlue) * mix);
return `rgb(${red}, ${green}, ${blue})`;
}
function interpolateRgba(from: string, to: string, mix: number): string {
const [fromRed, fromGreen, fromBlue, fromAlpha] = parseRgbaColor(from);
const [toRed, toGreen, toBlue, toAlpha] = parseRgbaColor(to);
const red = Math.round(fromRed + (toRed - fromRed) * mix);
const green = Math.round(fromGreen + (toGreen - fromGreen) * mix);
const blue = Math.round(fromBlue + (toBlue - fromBlue) * mix);
const alpha = fromAlpha + (toAlpha - fromAlpha) * mix;
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
}
function buildHeroScrollColors(darkOverlayOpacity: number) {
return {
backgroundColor: LIGHT_BACKGROUND,
darkOverlayOpacity,
headingColor: interpolateHex(
LIGHT_HEADING,
DARK_HEADING,
darkOverlayOpacity,
),
bodyColor: interpolateRgba(LIGHT_BODY, DARK_BODY, darkOverlayOpacity),
menuBackgroundColor: interpolateHex(
LIGHT_BACKGROUND,
DARK_BACKGROUND,
darkOverlayOpacity,
),
patternOpacity: darkOverlayOpacity * 0.4,
};
}
export function getHeroScrollColors(colorMix: number, panelMix = colorMix) {
return buildHeroScrollColors(getDarkOverlayOpacity(colorMix, panelMix));
}
export function getHeroScrollColorsFromOpacity(darkOverlayOpacity: number) {
return buildHeroScrollColors(darkOverlayOpacity);
}
export function getHeroMenuScheme(darkOverlayOpacity: number) {
return darkOverlayOpacity >= 0.55 ? 'secondary' : 'primary';
}
type GetHeroMenuColorMixOptions = {
aiPanelRect: DOMRect | null;
colorMix: number;
introPanelRect: DOMRect | null;
isScrollDriven: boolean;
navHeight: number;
panelMix: number;
trackRect: DOMRect | null;
viewportHeight: number;
};
function getHeroMobileMenuDarkOpacity({
aiPanelRect,
introPanelRect,
navHeight,
trackRect,
viewportHeight,
}: {
aiPanelRect: DOMRect;
introPanelRect: DOMRect | null;
navHeight: number;
trackRect: DOMRect;
viewportHeight: number;
}): number {
const introAiBoundaryY = introPanelRect?.bottom ?? aiPanelRect.top;
const scrollPastBoundary = navHeight + HERO_MOBILE_MENU_LEAD_PX - introAiBoundaryY;
let menuDarkOpacity = 0;
if (scrollPastBoundary > 0) {
const linearMix = Math.min(
1,
scrollPastBoundary / HERO_MOBILE_MENU_TRANSITION_PX,
);
// Mobile uses a solid panel background under the nav — match it directly
// instead of the slower overlay curve used for desktop scroll blending.
menuDarkOpacity = smoothstep(linearMix);
}
const isExitingHero =
trackRect.top < 0 && trackRect.bottom < viewportHeight;
if (isExitingHero) {
const exitProgress =
(viewportHeight - trackRect.bottom) /
Math.max(viewportHeight - navHeight, 1);
const exitFade = smoothstep(Math.min(1, Math.max(0, exitProgress)));
menuDarkOpacity = menuDarkOpacity * (1 - exitFade);
}
return menuDarkOpacity;
}
export function getHeroMenuColorMix({
aiPanelRect,
colorMix,
introPanelRect,
isScrollDriven,
navHeight,
panelMix,
trackRect,
viewportHeight,
}: GetHeroMenuColorMixOptions): number {
if (!trackRect || trackRect.bottom <= navHeight) {
return 0;
}
if (!isScrollDriven) {
if (!aiPanelRect || aiPanelRect.bottom <= navHeight) {
return 0;
}
return getHeroMobileMenuDarkOpacity({
aiPanelRect,
introPanelRect,
navHeight,
trackRect,
viewportHeight,
});
}
const menuColorMix = Math.max(colorMix, panelMix * 0.95);
let menuDarkOpacity = getDarkOverlayOpacity(menuColorMix, panelMix);
const isExitingHero =
trackRect.top < 0 && trackRect.bottom < viewportHeight;
if (isExitingHero) {
const exitProgress =
(viewportHeight - trackRect.bottom) /
Math.max(viewportHeight - navHeight, 1);
const exitFade = smoothstep(Math.min(1, Math.max(0, exitProgress)));
menuDarkOpacity = menuDarkOpacity * (1 - exitFade);
}
return menuDarkOpacity;
}
export function getHeroScrollMotion(
panelMix: number,
panelStepPixels: number,
) {
return {
trackTranslateY: -panelMix * panelStepPixels,
};
}
@@ -0,0 +1,102 @@
'use client';
import {
createContext,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
type RefObject,
} from 'react';
import { Menu } from '@/sections/Menu/components';
import type {
MenuNavItemType,
MenuScheme,
MenuSocialLinkType,
} from '@/sections/Menu/types';
import { breakpoints } from '@/theme/breakpoints';
import {
getHeroMenuScheme,
getHeroScrollColorsFromOpacity,
} from './hero-scroll-colors';
type ProductHeroMenuContextValue = {
menuSectionRef: RefObject<HTMLElement | null>;
setMenuDarkOpacity: (darkOverlayOpacity: number) => void;
};
const ProductHeroMenuContext =
createContext<ProductHeroMenuContextValue | null>(null);
export function useProductHeroMenuSync() {
return useContext(ProductHeroMenuContext);
}
type ProductHeroMenuSyncProps = {
children: ReactNode;
navItems: MenuNavItemType[];
socialLinks: MenuSocialLinkType[];
};
export function ProductHeroMenuSync({
children,
navItems,
socialLinks,
}: ProductHeroMenuSyncProps) {
const menuSectionRef = useRef<HTMLElement | null>(null);
const [menuDarkOpacity, setMenuDarkOpacity] = useState(0);
const [enableBackdropBlur, setEnableBackdropBlur] = useState(false);
useEffect(() => {
const mediaQuery = window.matchMedia(
`(min-width: ${breakpoints.md}px)`,
);
const updateBackdropBlur = () => {
setEnableBackdropBlur(mediaQuery.matches);
};
updateBackdropBlur();
mediaQuery.addEventListener('change', updateBackdropBlur);
return () => mediaQuery.removeEventListener('change', updateBackdropBlur);
}, []);
const heroMenuColors = getHeroScrollColorsFromOpacity(menuDarkOpacity);
const menuScheme: MenuScheme = getHeroMenuScheme(
heroMenuColors.darkOverlayOpacity,
);
const menuBackgroundColor = heroMenuColors.menuBackgroundColor;
const contextValue = useMemo(
() => ({
menuSectionRef,
setMenuDarkOpacity,
}),
[],
);
return (
<ProductHeroMenuContext.Provider value={contextValue}>
<Menu.Root
backgroundColor={menuBackgroundColor}
enableBackdropBlur={enableBackdropBlur}
navItems={navItems}
scheme={menuScheme}
sectionRef={menuSectionRef}
socialLinks={socialLinks}
transitionBackgroundColor
>
<Menu.Logo scheme={menuScheme} />
<Menu.Nav navItems={navItems} scheme={menuScheme} />
<Menu.Social scheme={menuScheme} socialLinks={socialLinks} />
<Menu.Cta scheme={menuScheme} />
</Menu.Root>
{children}
</ProductHeroMenuContext.Provider>
);
}
@@ -0,0 +1 @@
export const PRODUCT_HERO_AI_TABS_ID = 'product-hero-ai-tabs';
@@ -1,36 +1,71 @@
import { type RefObject, useEffect, useState } from 'react';
'use client';
const TRANSITION_POINT = 0.25;
const MOBILE_BREAKPOINT = 921;
import { type RefObject, useCallback, useEffect, useState } from 'react';
import { useScrollProgress } from '@/lib/scroll/use-scroll-progress';
import { breakpoints } from '@/theme/breakpoints';
import { mapScrollToColorMix, mapScrollToPanelMix } from './hero-scroll-colors';
export type HeroScrollState = {
colorMix: number;
isScrollDriven: boolean;
panelMix: number;
phase: 0 | 1;
progress: number;
};
const INITIAL_SCROLL_STATE: HeroScrollState = {
colorMix: 0,
isScrollDriven: false,
panelMix: 0,
phase: 0,
progress: 0,
};
export function useHeroScrollProgress(
trackRef: RefObject<HTMLDivElement | null>,
): number {
const [phase, setPhase] = useState(0);
): HeroScrollState {
const [scrollState, setScrollState] =
useState<HeroScrollState>(INITIAL_SCROLL_STATE);
const [isScrollDriven, setIsScrollDriven] = useState(false);
useEffect(() => {
const element = trackRef.current;
if (!element) return;
if (window.innerWidth < MOBILE_BREAKPOINT) return;
const handleScroll = () => {
const rect = element.getBoundingClientRect();
const scrollableDistance = element.offsetHeight - window.innerHeight;
if (scrollableDistance <= 0) return;
const scrolled = -rect.top;
const progress = Math.max(0, Math.min(1, scrolled / scrollableDistance));
setPhase(progress >= TRANSITION_POINT ? 1 : 0);
const updateScrollMode = () => {
setIsScrollDriven(window.innerWidth >= breakpoints.md);
};
window.addEventListener('scroll', handleScroll, { passive: true });
handleScroll();
updateScrollMode();
window.addEventListener('resize', updateScrollMode);
return () => window.removeEventListener('scroll', handleScroll);
}, [trackRef]);
return () => window.removeEventListener('resize', updateScrollMode);
}, []);
return phase;
useEffect(() => {
if (!isScrollDriven) {
setScrollState(INITIAL_SCROLL_STATE);
}
}, [isScrollDriven]);
const handleScrollProgress = useCallback((progress: number) => {
const colorMix = mapScrollToColorMix(progress);
const panelMix = mapScrollToPanelMix(progress);
setScrollState({
colorMix,
isScrollDriven: true,
panelMix,
phase: panelMix >= 0.5 ? 1 : 0,
progress,
});
}, []);
useScrollProgress(trackRef, handleScrollProgress, {
enabled: isScrollDriven,
});
return {
...scrollState,
isScrollDriven,
};
}
@@ -12,7 +12,12 @@ import { theme } from '@/theme';
import { Drawer } from '@base-ui/react/drawer';
import { msg } from '@lingui/core/macro';
import { styled } from '@linaria/react';
import { useCallback, useState, type ReactNode } from 'react';
import {
useCallback,
useState,
type ReactNode,
type Ref,
} from 'react';
import { CloseDrawerWhenNavigationExpandsEffect } from './../effect-components/CloseDrawerWhenNavigationExpandsEffect';
import { ScrollTrackingEffect } from './../effect-components/ScrollTrackingEffect';
import { MenuDrawer } from './Drawer';
@@ -20,6 +25,7 @@ import { MenuDrawer } from './Drawer';
const StyledSection = styled.section<{
$enableBackdropBlur: boolean;
$isElevated: boolean;
$transitionBackgroundColor: boolean;
}>`
/* Safari < 18 still requires the -webkit- prefix for backdrop-filter; the
* unprefixed property is no-op on those versions and the menu would render
@@ -35,7 +41,10 @@ const StyledSection = styled.section<{
min-width: 0;
position: sticky;
top: 0;
transition: box-shadow 0.2s cubic-bezier(0.16, 1, 0.3, 1);
transition: ${({ $transitionBackgroundColor }) =>
$transitionBackgroundColor
? 'background-color 0.2s ease, box-shadow 0.2s cubic-bezier(0.16, 1, 0.3, 1)'
: 'box-shadow 0.2s cubic-bezier(0.16, 1, 0.3, 1)'};
width: 100%;
z-index: ${theme.zIndex.stickyHeader};
`;
@@ -86,8 +95,10 @@ type RootProps = {
scrolledBackgroundColor?: string;
scrolledSurfaceColor?: string;
scheme: MenuScheme;
surfaceColor?: string;
sectionRef?: Ref<HTMLElement>;
socialLinks: MenuSocialLinkType[];
surfaceColor?: string;
transitionBackgroundColor?: boolean;
};
export function Root({
@@ -98,8 +109,10 @@ export function Root({
scrolledBackgroundColor,
scrolledSurfaceColor,
scheme,
surfaceColor,
sectionRef,
socialLinks,
surfaceColor,
transitionBackgroundColor = false,
}: RootProps) {
const { i18n } = useLingui();
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
@@ -149,8 +162,10 @@ export function Root({
/>
<ScrollTrackingEffect onScrollStateChange={handleScrollStateChange} />
<StyledSection
ref={sectionRef}
$enableBackdropBlur={enableBackdropBlur}
$isElevated={isScrolling || hasScrolled}
$transitionBackgroundColor={transitionBackgroundColor}
style={{ backgroundColor: resolvedBackgroundColor }}
>
<StyledContainer>
@@ -5,6 +5,9 @@ import { theme } from '@/theme';
import { styled } from '@linaria/react';
import { TabButton } from './TabButton';
import { SlidingTabButtons } from './sliding-tab-buttons';
type TabButtonsSelectionStyle = 'default' | 'sliding';
const TabButtonsGrid = styled.div`
display: grid;
@@ -26,6 +29,7 @@ type TabButtonsProps = {
activeIndex: number;
idPrefix: string;
onSelect: (index: number) => void;
selectionStyle?: TabButtonsSelectionStyle;
tabs: TabType[];
};
@@ -33,8 +37,20 @@ export function TabButtons({
activeIndex,
idPrefix,
onSelect,
selectionStyle = 'default',
tabs,
}: TabButtonsProps) {
if (selectionStyle === 'sliding') {
return (
<SlidingTabButtons
activeIndex={activeIndex}
idPrefix={idPrefix}
onSelect={onSelect}
tabs={tabs}
/>
);
}
return (
<TabButtonsGrid role="tablist">
{tabs.map((tab, index) => (
@@ -0,0 +1,214 @@
'use client';
import { useLayoutEffect, useRef, useState } from 'react';
import { useLingui } from '@lingui/react';
import { INFORMATIVE_ICONS } from '@/icons';
import type { TabType } from '@/sections/Tabs/types';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
const TAB_INDICATOR_INSET_PX = 1;
const TAB_INDICATOR_TRANSITION_MS = 240;
const TabList = styled.div`
display: grid;
gap: ${theme.spacing(2)};
grid-template-columns: minmax(0, max-content);
justify-content: center;
position: relative;
width: 100%;
@media (min-width: ${theme.breakpoints.md}px) {
grid-template-columns: repeat(2, minmax(0, max-content));
}
@media (min-width: ${theme.breakpoints.lg}px) {
grid-template-columns: repeat(4, minmax(0, max-content));
}
`;
const SelectionIndicator = styled.div`
background-color: ${theme.colors.primary.background[100]};
border-radius: ${theme.radius(2)};
pointer-events: none;
position: absolute;
transition:
height ${TAB_INDICATOR_TRANSITION_MS}ms ease-out,
transform ${TAB_INDICATOR_TRANSITION_MS}ms ease-out,
width ${TAB_INDICATOR_TRANSITION_MS}ms ease-out;
z-index: 0;
@media (prefers-reduced-motion: reduce) {
transition: none;
}
`;
const TabButtonLabel = styled.span`
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const SlidingTabButton = styled.button`
align-items: center;
background: transparent;
border: 1px solid ${theme.colors.secondary.border[10]};
border-radius: ${theme.radius(2)};
box-sizing: border-box;
color: ${theme.colors.secondary.text[100]};
cursor: pointer;
display: grid;
font-family: ${theme.font.family.sans};
font-size: ${theme.font.size(3.5)};
font-weight: ${theme.font.weight.regular};
gap: ${theme.spacing(2)};
grid-template-columns: minmax(0, 1fr) auto;
line-height: ${theme.lineHeight(3.5)};
max-width: 100%;
min-width: 0;
padding: ${theme.spacing(2)};
position: relative;
text-align: left;
transition:
border-color ${TAB_INDICATOR_TRANSITION_MS}ms ease-out,
color ${TAB_INDICATOR_TRANSITION_MS}ms ease-out;
z-index: 1;
&[data-active='true'] {
border-color: transparent;
color: ${theme.colors.primary.text[100]};
}
&:focus-visible {
outline: 1px solid ${theme.colors.highlight[100]};
outline-offset: 1px;
}
`;
const TabIconBox = styled.span`
background-color: ${theme.colors.highlight[100]};
border-radius: ${theme.radius(1)};
display: grid;
height: ${theme.spacing(6)};
overflow: hidden;
place-items: center;
transition: background-color ${TAB_INDICATOR_TRANSITION_MS}ms ease-out;
width: ${theme.spacing(6)};
& svg {
display: block;
max-height: 100%;
max-width: 100%;
}
&[data-active='true'] {
background-color: ${theme.colors.primary.text[10]};
}
`;
type IndicatorPosition = {
height: number;
width: number;
x: number;
y: number;
};
const INITIAL_INDICATOR_POSITION: IndicatorPosition = {
height: 0,
width: 0,
x: 0,
y: 0,
};
type SlidingTabButtonsProps = {
activeIndex: number;
idPrefix: string;
onSelect: (index: number) => void;
tabs: TabType[];
};
export function SlidingTabButtons({
activeIndex,
idPrefix,
onSelect,
tabs,
}: SlidingTabButtonsProps) {
const { i18n } = useLingui();
const tabListRef = useRef<HTMLDivElement>(null);
const tabButtonRefs = useRef<Array<HTMLButtonElement | null>>([]);
const [indicatorPosition, setIndicatorPosition] = useState(
INITIAL_INDICATOR_POSITION,
);
useLayoutEffect(() => {
const tabList = tabListRef.current;
const activeTabButton = tabButtonRefs.current[activeIndex];
if (!tabList || !activeTabButton) {
return;
}
const updateIndicatorPosition = () => {
const listRect = tabList.getBoundingClientRect();
const tabRect = activeTabButton.getBoundingClientRect();
setIndicatorPosition({
height: tabRect.height - TAB_INDICATOR_INSET_PX * 2,
width: tabRect.width - TAB_INDICATOR_INSET_PX * 2,
x: tabRect.left - listRect.left + TAB_INDICATOR_INSET_PX,
y: tabRect.top - listRect.top + TAB_INDICATOR_INSET_PX,
});
};
updateIndicatorPosition();
const resizeObserver = new ResizeObserver(updateIndicatorPosition);
resizeObserver.observe(tabList);
resizeObserver.observe(activeTabButton);
return () => resizeObserver.disconnect();
}, [activeIndex, tabs]);
return (
<TabList ref={tabListRef} role="tablist">
<SelectionIndicator
style={{
height: indicatorPosition.height,
transform: `translate3d(${indicatorPosition.x}px, ${indicatorPosition.y}px, 0)`,
width: indicatorPosition.width,
}}
/>
{tabs.map((tab, index) => {
const isActive = index === activeIndex;
const Icon =
INFORMATIVE_ICONS[tab.icon as keyof typeof INFORMATIVE_ICONS];
const iconColor = isActive
? theme.colors.highlight[100]
: theme.colors.secondary.text[100];
return (
<SlidingTabButton
aria-controls={`${idPrefix}-panel`}
aria-selected={isActive}
data-active={String(isActive)}
id={`${idPrefix}-tab-${index}`}
key={index}
onClick={() => onSelect(index)}
ref={(element) => {
tabButtonRefs.current[index] = element;
}}
role="tab"
type="button"
>
<TabButtonLabel>{i18n._(tab.body)}</TabButtonLabel>
<TabIconBox data-active={String(isActive)}>
{Icon ? <Icon color={iconColor} size={16} /> : null}
</TabIconBox>
</SlidingTabButton>
);
})}
</TabList>
);
}