Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9483104ee4 | ||
|
|
e37517dc3d | ||
|
|
a4161833d0 | ||
|
|
ac03df1f5f |
@@ -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;
|
||||
}
|
||||
|
||||
+331
-147
@@ -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>
|
||||
);
|
||||
|
||||
+58
-6
@@ -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>
|
||||
);
|
||||
|
||||
+271
@@ -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,
|
||||
};
|
||||
}
|
||||
+102
@@ -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>
|
||||
);
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const PRODUCT_HERO_AI_TABS_ID = 'product-hero-ai-tabs';
|
||||
+60
-25
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user