Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions src/components/marketing/ProductMotif.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ const workflowLanePath = (offset: number, rightX = 262, leftX = 58) => {
].join(" ");
};

// Card motifs draw one 320×400 cluster stretched over the 4/5 plate. Heroes
// Card motifs draw one 320×400 cluster on a fixed 4/5 canvas. Compact cards
// crop that canvas instead of squeezing its shapes and animation paths. Heroes
// are wide and short, so slicing that same viewBox magnifies the artwork ~4×
// and the center cut-out mask swallows it — the shapes stop reading as the
// card imagery. Instead heroes repeat the cluster across a 1600×400 field so
Expand Down Expand Up @@ -485,7 +486,13 @@ export const ProductMotif = ({ productId, surface }: ProductMotifProps) => {

return (
<div aria-hidden="true" className={containerClassName(productId, surface)}>
{motif}
{surface === "card" ? (
<div className="absolute left-0 top-1/2 aspect-[4/5] w-full -translate-y-1/2">
{motif}
</div>
) : (
motif
)}
</div>
);
};
86 changes: 86 additions & 0 deletions src/components/marketing/sections/LatestPostCallout.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
.story {
--post-width: min(72rem, calc(100vw - 2rem));
position: relative;
z-index: 20;
padding: 3rem 1rem 4rem;
}

.track {
position: relative;
width: 100%;
}

.card {
position: relative;
display: block;
width: 100%;
max-width: 72rem;
margin-inline: auto;
overflow: hidden;
border-radius: 24px;
}

.card::before {
content: "";
position: absolute;
z-index: 1;
inset: 0;
border-top: 1px solid rgb(255 255 255 / 60%);
border-radius: inherit;
pointer-events: none;
}

.pill {
display: none;
}

.content {
position: relative;
width: var(--post-width);
}

/* A 48px sticky carrier keeps the bottom edge stable as the card grows up.
Its track ends at the callout's resting position, so it releases into the
page naturally, without fixed positioning, reparenting or scroll locking. */
.story[data-enhanced] {
margin-top: -80px;
padding-top: 0;
}

.story[data-enhanced] .track {
height: calc(var(--post-height, 320px) + 144px);
}

.story[data-enhanced] .sticky {
position: sticky;
top: calc(var(--post-viewport-height, 100svh) - 80px);
height: 48px;
}

.story[data-enhanced] .card {
position: absolute;
inset-inline: 0;
bottom: 0;
}

.story[data-enhanced] .pill {
position: absolute;
inset: 0 0 auto;
display: flex;
height: 46px;
align-items: center;
}

@media (min-width: 48rem) {
.story {
--post-width: min(72rem, calc(100vw - 6rem));
padding-inline: 3rem;
}
}

@media (min-width: 1681px) {
.story {
--post-width: min(72rem, calc(100vw - 7rem));
padding-inline: 3.5rem;
}
}
177 changes: 177 additions & 0 deletions src/components/marketing/sections/LatestPostCallout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
"use client";

import { useEffect, useRef, useState } from "react";
import { motion, useMotionValue, useScroll, useTransform } from "framer-motion";
import { ArrowRight } from "lucide-react";
import { canonicalizeInternalHref } from "@/lib/internalHref";
import { EYEBROW_CLASS, SECTION_H2_CLASS } from "../typography";
import styles from "./LatestPostCallout.module.css";

export interface LatestPost {
title: string;
description: string;
href: string;
image: { src: string; width: number; height: number; alt: string } | null;
}

export function LatestPostCallout({ post }: { post: LatestPost }) {
const [reducedMotion, setReducedMotion] = useState(false);
const storyRef = useRef<HTMLElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const [enhanced, setEnhanced] = useState(false);
const expandedHeight = useMotionValue(320);
const { scrollYProgress } = useScroll({
target: storyRef,
offset: ["start end", "end end"],
});
const progress = useTransform(() => {
const fullHeight = expandedHeight.get();
// The story includes the track's 144px spacing and 64px bottom padding.
const travel = scrollYProgress.get() * (fullHeight + 208) - 80;
// Follow as a pill first. Finish expanding exactly where sticky releases.
const t = Math.max(0, Math.min(1, (travel - 128) / (fullHeight - 32)));
const eased = t * t * (3 - 2 * t);
// Never let the growing top edge cross the hero: reserve at least 24px.
const clearance = Math.max(0, (travel - 104) / (fullHeight - 48));
return Math.min(eased, clearance, 1);
});
const height = useTransform(
() => 48 + (expandedHeight.get() - 48) * progress.get(),
);
const maxWidth = useTransform(progress, [0, 1], [896, 1152]);
const pillOpacity = useTransform(progress, [0, 0.18], [1, 0]);
const contentOpacity = useTransform(progress, [0.22, 0.72], [0, 1]);
const contentY = useTransform(progress, [0.22, 1], [12, 0]);
const imageOpacity = useTransform(progress, [0.38, 0.86], [0, 1]);
const imageScale = useTransform(progress, [0.22, 1], [1.025, 1]);

useEffect(() => {
const content = contentRef.current;
const track = trackRef.current;
if (!content || !track) return;
const motionPreference = window.matchMedia(
"(prefers-reduced-motion: reduce)",
);

const measure = () => {
const measuredHeight = content.offsetHeight + 2;
const viewportHeight = document.documentElement.clientHeight;
expandedHeight.set(measuredHeight);
track.style.setProperty("--post-height", `${measuredHeight}px`);
track.style.setProperty("--post-viewport-height", `${viewportHeight}px`);
setReducedMotion(motionPreference.matches);
// Short screens, reduced motion and no-JS get a normal in-flow card.
setEnhanced(
!motionPreference.matches && viewportHeight > measuredHeight + 160,
);
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(content);
window.addEventListener("resize", measure);
motionPreference.addEventListener("change", measure);
return () => {
observer.disconnect();
window.removeEventListener("resize", measure);
motionPreference.removeEventListener("change", measure);
};
}, [expandedHeight]);

return (
<section
ref={storyRef}
aria-label="Latest update"
className={styles.story}
data-latest-post-story
data-enhanced={enhanced || undefined}
>
<div ref={trackRef} className={styles.track}>
<div className={styles.sticky}>
<motion.a
href={canonicalizeInternalHref(post.href)}
aria-label={`Latest update: ${post.title}`}
className={`${styles.card} border border-ink/10 bg-paper/90 backdrop-blur-[18px] backdrop-saturate-[1.4] transition-colors duration-300 hover:border-ink/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-pine focus-visible:ring-offset-2 focus-visible:ring-offset-paper supports-[backdrop-filter]:bg-white/40 supports-[backdrop-filter]:hover:bg-white/55 motion-reduce:transition-none`}
style={
enhanced
? { height, maxWidth }
: { height: "auto", maxWidth: 1152 }
}
initial={false}
animate="idle"
whileHover={reducedMotion ? undefined : "active"}
whileFocus={reducedMotion ? undefined : "active"}
>
<motion.div
aria-hidden="true"
className={`${styles.pill} gap-3 px-4`}
style={{ opacity: enhanced ? pillOpacity : 0 }}
>
<span className="min-w-0 flex-1 truncate text-sm font-medium text-ink">
<span className="hidden text-ink-soft sm:inline">
Latest update:{" "}
</span>
{post.title}
</span>
<CalloutArrow />
</motion.div>

<div ref={contentRef} className={styles.content}>
<motion.div
className={`grid items-center gap-6 p-6 md:min-h-[320px] md:gap-10 md:p-8 ${post.image ? "md:grid-cols-2" : ""}`}
style={
enhanced
? { opacity: contentOpacity, y: contentY }
: { opacity: 1, y: 0 }
}
>
<div>
<p className={`${EYEBROW_CLASS} mb-3`}>Latest update</p>
<h2 className={SECTION_H2_CLASS}>{post.title}</h2>
{post.description ? (
<p className="mt-4 max-w-lg text-[15px] leading-relaxed text-ink-soft">
{post.description}
</p>
) : null}
<span className="mt-6 inline-flex items-center gap-2 text-sm font-medium text-pine">
Read update <CalloutArrow />
</span>
</div>
{post.image ? (
<motion.div
className="overflow-hidden rounded-xl border border-ink/10"
style={{ opacity: enhanced ? imageOpacity : 1 }}
>
<motion.img
src={post.image.src}
width={post.image.width}
height={post.image.height}
alt={post.image.alt}
loading="eager"
decoding="async"
className="block h-auto w-full"
style={{ scale: enhanced ? imageScale : 1 }}
/>
</motion.div>
) : null}
</motion.div>
</div>
</motion.a>
</div>
</div>
</section>
);
}

function CalloutArrow() {
return (
<motion.span
aria-hidden="true"
variants={{ idle: { x: 0 }, active: { x: 3 } }}
transition={{ duration: 0.35, ease: [0.22, 1, 0.36, 1] }}
className="flex shrink-0 text-pine"
>
<ArrowRight className="h-4 w-4" />
</motion.span>
);
}
Loading
Loading