Site
Customer stories
Scrollable customer story cards with drag, arrow, and pagination controls.
$ npx context-ds add customer-storiesUsage
Use it for
Case studies on the home and customers pages, and in a grid on the customers listing (the card alone). tone sets the controls for a white or a dark section; showRules draws the dotted rules above and below the row.
Avoid
- Per-customer card colors
- Autoplay
- Scroll-jacking the page
- More than one call to action per card
Dependencies: lucide-react
Example
examples/customer-stories.tsx
import { CustomerStoriesCarousel, CustomerStoryCard } from "@/components/site/customer-stories";import { Button } from "@/components/ui/button";import { DotGlyph } from "@/components/ui/dot-glyph";// The brand draws each logo in white dots on the blue; a dot glyph stands in for the real artwork here.const MARK = ["0022200", "0211120", "2110112", "2101012", "2110112", "0211120", "0022200"];function Mark() { return <DotGlyph rows={MARK} className="size-28" />;}export function Site() { return ( <div className="flex w-full flex-col gap-10"> <div className="mx-auto flex max-w-2xl flex-col items-center text-center"> <h2 className="mb-6 text-h2 leading-tight tracking-tight text-balance text-fg"> <span className="text-fg-brand">How customers shipped</span> with Context.dev </h2> <p className="mx-auto max-w-md text-body leading-relaxed text-fg-muted">From scrappy startups to Fortune 500s, teams ship brand-powered features in days instead of quarters.</p> <Button asChild variant="pill-outline" className="mt-8"> <a href="https://context.dev/customers">See all customers</a> </Button> </div> <CustomerStoriesCarousel tone="light" showRules> <CustomerStoryCard key="mastra" company="Mastra" logo={<Mark />} date="September 12, 2026" dateTime="2026-09-12" description="Mastra built an onboarding MVP in an hour with Context.dev, pre-filling names and company details and tailoring the experience to each user's goals." href="https://context.dev/blog/mastra-personalizes-onboarding-with-context-dev" /> <CustomerStoryCard key="tinfoil" company="Tinfoil" logo={<Mark />} date="September 11, 2026" dateTime="2026-09-11" description="Tinfoil added Context.dev scraping and metadata to its web search agent in under five minutes." href="https://context.dev/blog/tinfoil-switches-to-context-dev-for-zero-data-retention-web-scraping" /> <CustomerStoryCard key="instant" company="Instant" logo={<Mark />} date="August 24, 2026" dateTime="2026-08-24" description="Instant uses Context.dev to give its AI Shopify builder brand kits, URL-based inspiration, and reliable page context." href="https://context.dev/blog/instant-switches-from-firecrawl-to-context-dev-in-under-a-day" /> <CustomerStoryCard key="similarweb" company="Similarweb" logo={<Mark />} date="July 1, 2026" dateTime="2026-07-01" description="Raz uses Context.dev to give Similarweb AI agents the datapoints they need, combining scrape and brand APIs instead of building the web context layer in house." href="https://context.dev/blog/similarweb-10x-ai-agents-with-context-dev" /> <CustomerStoryCard key="mintlify" company="Mintlify" logo={<Mark />} date="March 4, 2026" dateTime="2026-03-04" description="Mintlify turns GitHub repos into branded docs sites, pulling each company's logo and colors from Context.dev at sign-up." href="https://context.dev/blog/mintlify-turns-github-repos-into-branded-docs-sites-with-brand-dev" /> </CustomerStoriesCarousel> </div> );}Source
components/site/customer-stories.tsx
"use client";import * as React from "react";import { Check, ChevronLeft, ChevronRight } from "lucide-react";import { cn } from "@/lib/utils";// The site's customer stories: tall brand-blue cards (the logos, not per-customer colors, tell them apart) in a row that// runs the full width of the page, so the next card peeks in past the right edge (and the previous ones past the left// once scrolled), with arrows and one dot per card under it. The row is a native scroll container: touch keeps the// browser's own swipe, a mouse drag scrubs it and a flick glides on, and the arrows scroll a card at a time. The first// card starts on the content edge. `tone` sets the controls (and the optional dotted rules) for a white or dark section.type CustomerStoriesTone = "light" | "dark";// Mouse drag: movement under this is still a click on a card, not a drag.const DRAG_THRESHOLD = 4;// After a drag is let go it keeps gliding, keeping this share of its speed every 16ms, from at most MAX_FLING px/ms, so// even a hard flick carries on for about two cards rather than off to the end.const MOMENTUM_FRICTION = 0.93;const MAX_FLING = 3;// A click this soon after the row scrolled is the end of a drag, not a click on a card.const CLICK_AFTER_SCROLL_MS = 350;type Drag = { pointerId: number; startX: number; startScroll: number; lastX: number; lastTime: number; velocity: number; moved: boolean };// The dotted rules above and below the row: 4px dashes, black at 12 percent on white, white at 15 percent on black.const RULE = { light: "bg-[repeating-linear-gradient(to_right,color-mix(in_srgb,var(--ds-color-black)_12%,transparent)_0,color-mix(in_srgb,var(--ds-color-black)_12%,transparent)_4px,transparent_4px,transparent_8px)]", dark: "bg-[repeating-linear-gradient(to_right,color-mix(in_srgb,var(--ds-color-white)_15%,transparent)_0,color-mix(in_srgb,var(--ds-color-white)_15%,transparent)_4px,transparent_4px,transparent_8px)]",};function CustomerStoriesCarousel({ children, tone = "light", showRules = false, label = "Customer stories", className,}: { children: React.ReactNode; tone?: CustomerStoriesTone; showRules?: boolean; label?: string; className?: string;}) { const slides = React.Children.toArray(children); const rootRef = React.useRef<HTMLDivElement>(null); const scrollRef = React.useRef<HTMLDivElement>(null); const dragRef = React.useRef<Drag | null>(null); const momentum = React.useRef(0); const lastScroll = React.useRef(0); const [{ canPrev, canNext, active }, setState] = React.useState({ canPrev: false, canNext: true, active: 0 }); // The row breaks out to the viewport's width; the rail (the content column) is measured so the first card starts on // its edge and two (md) or three (lg) cards fill it exactly. React.useLayoutEffect(() => { const root = rootRef.current; const scroller = scrollRef.current; if (!root || !scroller) return; const measure = () => { const rect = root.getBoundingClientRect(); scroller.style.setProperty("--carousel-rail", `${rect.width}px`); scroller.style.setProperty("--carousel-inset", `${Math.max(0, rect.left)}px`); }; measure(); const observer = new ResizeObserver(measure); observer.observe(root); window.addEventListener("resize", measure); return () => { observer.disconnect(); window.removeEventListener("resize", measure); }; }, []); React.useEffect(() => () => cancelAnimationFrame(momentum.current), []); const update = React.useCallback(() => { const scroller = scrollRef.current; if (!scroller) return; lastScroll.current = Date.now(); const { scrollLeft, scrollWidth, clientWidth } = scroller; const atStart = scrollLeft <= 5; const atEnd = scrollLeft >= scrollWidth - clientWidth - 5; const els = Array.from(scroller.querySelectorAll<HTMLElement>("[data-slide]")); let next = 0; // At either end the row is clamped, so the closest card to the start edge would be wrong: pin to the first or last. if (atEnd) next = els.length - 1; else if (!atStart) { const edge = scroller.getBoundingClientRect().left + (parseFloat(getComputedStyle(scroller).getPropertyValue("--carousel-inset")) || 0); let best = Infinity; els.forEach((el, i) => { const distance = Math.abs(el.getBoundingClientRect().left - edge); if (distance < best) { best = distance; next = i; } }); } setState((s) => (s.canPrev === !atStart && s.canNext === !atEnd && s.active === next ? s : { canPrev: !atStart, canNext: !atEnd, active: next })); }, []); React.useEffect(() => { update(); window.addEventListener("resize", update); return () => window.removeEventListener("resize", update); }, [update]); const behavior = (): ScrollBehavior => (window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth"); const scrollBySlide = (direction: 1 | -1) => { const scroller = scrollRef.current; const slide = scroller?.querySelector<HTMLElement>("[data-slide]"); if (!scroller || !slide) return; const gap = parseFloat(getComputedStyle(slide.parentElement!).columnGap) || 0; scroller.scrollBy({ left: direction * (slide.offsetWidth + gap), behavior: behavior() }); }; const scrollToIndex = (i: number) => { const scroller = scrollRef.current; const target = scroller?.querySelectorAll<HTMLElement>("[data-slide]")[i]; if (!scroller || !target) return; const inset = parseFloat(getComputedStyle(scroller).getPropertyValue("--carousel-inset")) || 0; scroller.scrollTo({ left: target.offsetLeft - inset, behavior: behavior() }); }; // Mouse drag scrubs the row; touch keeps the browser's own swipe. const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => { if (event.pointerType !== "mouse" || event.button !== 0) return; cancelAnimationFrame(momentum.current); event.preventDefault(); dragRef.current = { pointerId: event.pointerId, startX: event.clientX, startScroll: event.currentTarget.scrollLeft, lastX: event.clientX, lastTime: event.timeStamp, velocity: 0, moved: false }; }; const onPointerMove = (event: React.PointerEvent<HTMLDivElement>) => { const drag = dragRef.current; if (!drag || drag.pointerId !== event.pointerId) return; const dx = event.clientX - drag.startX; if (!drag.moved) { if (Math.abs(dx) < DRAG_THRESHOLD) return; drag.moved = true; event.currentTarget.setPointerCapture(event.pointerId); } event.currentTarget.scrollLeft = drag.startScroll - dx; const elapsed = event.timeStamp - drag.lastTime; if (elapsed > 0) drag.velocity = drag.velocity * 0.6 + ((event.clientX - drag.lastX) / elapsed) * 0.4; drag.lastX = event.clientX; drag.lastTime = event.timeStamp; }; const onPointerEnd = (event: React.PointerEvent<HTMLDivElement>) => { const drag = dragRef.current; if (!drag || drag.pointerId !== event.pointerId) return; dragRef.current = null; if (!drag.moved) return; const scroller = event.currentTarget; if (scroller.hasPointerCapture(event.pointerId)) scroller.releasePointerCapture(event.pointerId); if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; // A pause before letting go means no fling. let velocity = event.timeStamp - drag.lastTime > 80 ? 0 : Math.max(-MAX_FLING, Math.min(MAX_FLING, -drag.velocity)); let last = performance.now(); const glide = (now: number) => { const elapsed = now - last; last = now; scroller.scrollLeft += velocity * elapsed; velocity *= MOMENTUM_FRICTION ** (elapsed / 16); if (Math.abs(velocity) > 0.02) momentum.current = requestAnimationFrame(glide); }; if (Math.abs(velocity) > 0.02) momentum.current = requestAnimationFrame(glide); }; const light = tone === "light"; const arrowClass = cn( "hidden size-10 shrink-0 items-center justify-center rounded-pill backdrop-blur-sm outline-none transition-[background-color,opacity] duration-200 ease-out focus-visible:ring-2 focus-visible:ring-focus-ring disabled:cursor-not-allowed disabled:opacity-35 motion-reduce:transition-none md:flex [&_svg]:size-5", light ? "bg-black/5 text-fg enabled:hover:bg-black/10" : "bg-white/10 text-white enabled:hover:bg-white/20", ); return ( <div ref={rootRef} data-slot="customer-stories-carousel" role="region" aria-roledescription="carousel" aria-label={label} className={cn("relative flex flex-col", className)}> <div className="relative left-1/2 w-screen max-w-[100vw] -translate-x-1/2"> {showRules && <div aria-hidden="true" className={cn("absolute top-0 left-0 h-px w-full", RULE[tone])} />} {/* Extra vertical room so focus rings are not clipped by the scroll container. */} <div ref={scrollRef} onScroll={update} onClickCapture={(event) => { if (Date.now() - lastScroll.current < CLICK_AFTER_SCROLL_MS && dragRef.current === null && event.detail > 0) { event.preventDefault(); event.stopPropagation(); } }} onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerEnd} onPointerCancel={onPointerEnd} className="relative -my-6 w-full overflow-x-auto overflow-y-hidden py-6 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden" > <div className="flex gap-8 pl-(--carousel-inset)"> {slides.map((child, i) => ( <div key={React.isValidElement(child) && child.key != null ? String(child.key) : i} data-slide="" role="group" aria-roledescription="slide" aria-label={`${i + 1} of ${slides.length}`} className="w-(--carousel-rail) shrink-0 md:w-[calc((var(--carousel-rail)-var(--ds-space-8))/2)] lg:w-[calc((var(--carousel-rail)-2*var(--ds-space-8))/3)]" > {child} </div> ))} {/* Room after the last card so it can rest on the content edge too (padding at the end of a scroller is not reliable). */} <div aria-hidden="true" className="-ml-8 w-(--carousel-inset) shrink-0" /> </div> </div> {showRules && <div aria-hidden="true" className={cn("absolute bottom-0 left-0 h-px w-full", RULE[tone])} />} </div> {slides.length > 1 && ( <div className="relative mx-auto mt-10 flex w-fit items-center gap-3"> <button type="button" onClick={() => scrollBySlide(-1)} disabled={!canPrev} className={arrowClass} aria-label="Previous stories"> <ChevronLeft aria-hidden="true" /> </button> <div className="flex items-center gap-2"> {slides.map((_, i) => ( <button key={i} type="button" onClick={() => scrollToIndex(i)} aria-label={`Go to card ${i + 1}`} aria-current={i === active || undefined} className="group -m-1 flex items-center justify-center rounded-pill p-3 outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"> <span className={cn( "block size-2.5 rounded-pill transition-[scale,background-color] duration-200 ease-out motion-reduce:transition-none", i === active ? (light ? "bg-fg" : "bg-white") : cn("scale-80", light ? "bg-fg/20 group-hover:bg-fg/40" : "bg-white/30 group-hover:bg-white/50"), )} /> </button> ))} </div> <button type="button" onClick={() => scrollBySlide(1)} disabled={!canNext} className={arrowClass} aria-label="Next stories"> <ChevronRight aria-hidden="true" /> </button> </div> )} </div> );}type CustomerStoryCardProps = { /** The customer's name, the card's heading. */ company: string; /** The logo, drawn white on the blue (the site samples it into dots); a check mark when left out. */ logo?: React.ReactNode; /** The story's summary. */ description: React.ReactNode; /** The date as shown, e.g. "September 12" (this year) or "September 12, 2025", and its machine-readable form. */ date: string; dateTime?: string; href: string; ctaLabel?: string; className?: string;};function CustomerStoryCard({ company, logo, description, date, dateTime, href, ctaLabel = "Read their story", className }: CustomerStoryCardProps) { return ( <a href={href} data-slot="customer-story-card" draggable={false} className={cn("group block h-full rounded-card outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2", className)}> <article className="relative flex h-full flex-col overflow-hidden rounded-card bg-brand sm:h-125"> <div className="relative flex h-60 shrink-0 items-center justify-center text-white sm:h-49 [&_img]:pointer-events-none">{logo ?? <Check aria-hidden="true" className="size-16" />}</div> <div className="flex flex-1 flex-col p-6 sm:p-8"> <time className="mb-1.5 block text-body-sm leading-snug text-white" dateTime={dateTime}> {date} </time> <h3 className="mb-3 text-h4 leading-snug font-normal tracking-normal text-white">{company}</h3> <p className="mb-6 line-clamp-3 text-body-lg leading-snug text-white sm:mb-8 sm:line-clamp-4">{description}</p> {/* The site's outline pill on blue; it answers the card's hover, since the whole card is the link. */} <span className="mt-auto inline-flex w-fit items-center justify-center rounded-pill border border-white px-5 py-3 text-body leading-snug whitespace-nowrap text-white transition-[background-color] duration-200 ease-out group-hover:bg-white/12 motion-reduce:transition-none"> {ctaLabel} </span> </div> </article> </a> );}export { CustomerStoriesCarousel, CustomerStoryCard, type CustomerStoryCardProps, type CustomerStoriesTone };Reference: src/components/sections/shared/CustomerStoriesCarousel.tsx; src/components/sections/shared/CustomerStoryCard.tsx; src/components/sections/shared/CustomerStories.tsx.