Logo stripDesign system
Site

Logo strip

Scrolling customer logos with hover pause and an optional rotating quote.

npx context-ds add logo-strip
On the site

Usage

Use it for

Customer logos right under the hero. The label's key phrase in blue; logos as image sources or nodes, each with an optional quote (a testimonial) and case study link. edgePattern fills the gutters beside the column with the hero's dot field.

Avoid

  • Logos in full color at rest
  • Color on hover for white artwork (set whiteArtwork)
  • A loop that runs under reduced motion
  • Logos without the label cell

Dependencies: testimonials, lucide-react

Example

examples/logo-strip.tsx
import { LogoStrip, type StripLogo } from "@/components/site/logo-strip";const names = ["Super", "Passionfroot", "mintlify", "chatwoot", "similarweb", "Klarna.", "Ferndesk", "DocsBot", "daily.dev", "Sixtyfour", "Comp AI", "Squad"];// Logos with a quote show it in a card that glides between them on hover; the rest only take their color.const quotes: Partial<Record<string, StripLogo["quote"]>> = {  mintlify: {    quote: "Onboarding couldn't be simpler. Self-serve sign-up gives you an API key immediately, the documentation is thorough, and we were integrating within 10 minutes.",    name: "Nick Khami",    role: "Engineering Manager @ Mintlify",    href: "https://context.dev/blog/mintlify-turns-github-repos-into-branded-docs-sites-with-brand-dev",  },  Super: {    quote: "Context.dev gave us company data for every signup on day one, so the product feels tailored before anyone touches a setting.",    name: "Hayden Barnes",    role: "Founding Engineer @ Super",  },};export function Site() {  return (    <LogoStrip      label={        <>          Powering agents and products with <span className="text-fg-brand">the world&apos;s data.</span>        </>      }      logos={names.map((name) => ({ name, quote: quotes[name], node: <span className="text-h5 font-medium whitespace-nowrap text-fg">{name}</span> }))}    />  );}

Source

components/site/logo-strip.tsx
"use client";import * as React from "react";import { ArrowRight, Quote } from "lucide-react";import type { Testimonial } from "@/components/site/testimonials";import { cn } from "@/lib/utils";type StripLogo = {  name: string;  src?: string;  node?: React.ReactNode;  /** The case study; the logo itself becomes the link. Falls back to the quote's `href`. */  href?: string;  /** A customer quote shown in a card that glides between logos on hover (from md up). */  quote?: Testimonial;  /** Logos that must keep their own colors (no gray at rest). */  keepColor?: boolean;  /** Marks drawn in white: they stay dark on hover, since their own colors would vanish on the white strip. */  whiteArtwork?: boolean;};// The whole loop takes 48 seconds, read as ambient rather than something to watch. Under the pointer (or while a quote// is open) the row coasts to a stop and eases back up when the pointer leaves, on the strong ease-out.const LOOP_MS = 48_000;const COAST_MS = 300;const MAX_FRAME_MS = 100;// The quote card: 340px wide, 12px from its logo and from the viewport's edges. A short grace before closing lets the// pointer slide between neighboring logos (the card glides) or across to the card (to click its link).const CARD_GAP_PX = 12;const VIEWPORT_MARGIN_PX = 12;const CLOSE_DELAY_MS = 140;type Anchor = { index: number; tileLeft: number; tileWidth: number; tileTop: number; tileBottom: number };type Placement = { x: number; y: number; below: boolean };const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(value, max));// Above the logo by default; below when the viewport (or the sticky site header) leaves no room above.function placeCard(anchor: Anchor, card: { width: number; height: number }, stage: DOMRect): Placement {  const headerBottom = Math.max(0, document.querySelector('[data-slot="site-header"]')?.getBoundingClientRect().bottom ?? 0);  const safeTop = headerBottom + VIEWPORT_MARGIN_PX - stage.top;  const safeBottom = window.innerHeight - VIEWPORT_MARGIN_PX - stage.top;  const minX = Math.max(0, VIEWPORT_MARGIN_PX - stage.left);  const maxX = Math.min(stage.width, window.innerWidth - VIEWPORT_MARGIN_PX - stage.left) - card.width;  const x = clamp(anchor.tileLeft + anchor.tileWidth / 2 - card.width / 2, minX, Math.max(minX, maxX));  const aboveTop = anchor.tileTop - CARD_GAP_PX - card.height;  const belowTop = anchor.tileBottom + CARD_GAP_PX;  const fitsAbove = aboveTop >= safeTop;  const fitsBelow = belowTop + card.height <= safeBottom;  const below = !fitsAbove && fitsBelow;  return below ? { x, y: belowTop, below } : { x, y: Math.max(aboveTop, Math.min(safeTop, belowTop)), below };}// Logos sit in gray (black at 55 percent). Color lands at once on hover, so the logo answers the pointer; only leaving// fades back. Only the logo itself counts as hovered, not the empty space around it in its tile.function logoClass(logo: StripLogo) {  return cn(    "max-h-12 w-auto rounded-card object-contain opacity-55 transition-[opacity,filter] duration-200 ease-out will-change-[opacity,filter] group-data-hovered/tile:opacity-100 group-data-hovered/tile:duration-0 motion-reduce:transition-none md:max-h-10",    !logo.keepColor && "brightness-0 grayscale",    !logo.keepColor && !logo.whiteArtwork && "group-data-hovered/tile:brightness-100 group-data-hovered/tile:grayscale-0",  );}function LogoTile({ logo, duplicate, onShow, onHide }: { logo: StripLogo; duplicate: boolean; onShow: (tile: HTMLElement) => void; onHide: (delay?: number) => void }) {  const href = logo.href ?? logo.quote?.href;  const mark = logo.node ? (    <span className={cn("flex items-center [&_svg]:h-8 [&_svg]:w-auto", logoClass(logo))}>{logo.node}</span>  ) : (    <img src={logo.src} alt={`${logo.name} logo`} loading="lazy" draggable={false} className={logoClass(logo)} />  );  const tileOf = (target: EventTarget) => (target as HTMLElement).closest<HTMLElement>("[data-logo-tile]");  const enter = (event: React.PointerEvent | React.FocusEvent) => {    const tile = tileOf(event.currentTarget);    if (!tile) return;    tile.toggleAttribute("data-hovered", true);    // A logo without a quote closes the open card at once, rather than leaving it pointing at the wrong logo.    if (logo.quote) onShow(tile);    else onHide(0);  };  const leave = (event: React.PointerEvent | React.FocusEvent) => {    tileOf(event.currentTarget)?.toggleAttribute("data-hovered", false);    onHide();  };  const handlers = { onPointerEnter: enter, onPointerLeave: leave, onFocus: enter, onBlur: leave };  return (    <div data-logo-tile="" className="group/tile relative flex h-16 w-36 shrink-0 items-center justify-center border-r border-rule bg-surface px-3 sm:h-30 sm:w-44 sm:px-6 md:h-25">      {href ? (        <a href={href} tabIndex={duplicate ? -1 : undefined} target="_blank" rel="noopener noreferrer" aria-label={logo.quote?.linkLabel ? `${logo.quote.linkLabel}: ${logo.name}` : logo.name} className="rounded-card outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2" {...handlers}>          {mark}        </a>      ) : (        <span className="flex" {...handlers}>          {mark}        </span>      )}      {logo.quote && (        <span aria-hidden="true" className="pointer-events-none absolute top-3 right-3 hidden size-6 items-center justify-center rounded-pill text-fg-brand transition-[background-color] duration-200 ease-out group-data-hovered/tile:bg-brand/20 motion-reduce:transition-none md:flex">          <Quote className="size-3" />        </span>      )}    </div>  );}function QuoteCardContent({ quote }: { quote: Testimonial }) {  return (    <div className="p-5">      <div className="mb-4 flex items-center gap-3">        {quote.avatar && <img src={quote.avatar} alt={quote.name} width={44} height={44} className="size-11 rounded-pill object-cover outline-1 -outline-offset-1 outline-hairline" />}        <div className="flex min-w-0 flex-col">          <p className="text-body-sm text-fg">{quote.name}</p>          <p className="text-caption text-fg-subtle">{quote.role}</p>        </div>      </div>      <p className="text-body-sm leading-relaxed text-fg-nav">&ldquo;{quote.quote}&rdquo;</p>      {quote.href && (        <a href={quote.href} target="_blank" rel="noopener noreferrer" className="group/case mt-4 inline-flex items-center gap-1 rounded-sm text-caption font-medium text-fg-brand outline-none hover:underline focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2">          <span>{quote.linkLabel ?? "Read the case study"}</span>          <ArrowRight aria-hidden="true" className="size-3" />        </a>      )}    </div>  );}// Moves the rows from script rather than a CSS animation, so the strip can coast to a stop and ease back up (tweening a// CSS animation's playbackRate restarts it every frame). It runs only while on screen, and not at all under reduced motion.function useEasedMarquee(stripRef: React.RefObject<HTMLDivElement | null>, paused: boolean, loopMs: number, onSettled: () => void) {  const state = React.useRef({ progress: 0, rate: 1, fromRate: 1, targetRate: 1, easeStartedAt: 0 });  const wake = React.useRef(() => {});  const settled = React.useRef(onSettled);  React.useLayoutEffect(() => {    settled.current = onSettled;  }, [onSettled]);  React.useEffect(() => {    const strip = stripRef.current;    if (!strip) return;    const reduce = window.matchMedia("(prefers-reduced-motion: reduce)");    const rows = Array.from(strip.querySelectorAll<HTMLElement>("[data-strip-row]"));    const motion = state.current;    let frame: number | null = null;    let lastAt: number | null = null;    let visible = false;    const step = (now: number) => {      const elapsed = lastAt === null ? 0 : Math.min(now - lastAt, MAX_FRAME_MS);      lastAt = now;      const wasEasing = motion.rate !== motion.targetRate;      const t = Math.min((now - motion.easeStartedAt) / COAST_MS, 1);      motion.rate = t === 1 ? motion.targetRate : motion.fromRate + (motion.targetRate - motion.fromRate) * (1 - (1 - t) ** 3);      motion.progress = (motion.progress + (motion.rate * elapsed) / loopMs) % 1;      for (const row of rows) row.style.transform = `translateX(${-motion.progress * 100}%)`;      if (wasEasing && motion.rate === motion.targetRate) settled.current();      if (motion.rate === 0 || !visible || reduce.matches) {        frame = null;        lastAt = null;        return;      }      frame = requestAnimationFrame(step);    };    const start = () => {      if (reduce.matches) {        for (const row of rows) row.style.transform = "";        return;      }      if (frame === null && visible) frame = requestAnimationFrame(step);    };    wake.current = start;    const observer = new IntersectionObserver(([entry]) => {      visible = entry.isIntersecting;      start();    });    observer.observe(strip);    reduce.addEventListener("change", start);    return () => {      observer.disconnect();      reduce.removeEventListener("change", start);      if (frame !== null) cancelAnimationFrame(frame);      wake.current = () => {};    };  }, [stripRef, loopMs]);  React.useEffect(() => {    const motion = state.current;    const targetRate = paused ? 0 : 1;    if (motion.targetRate === targetRate) return;    motion.fromRate = motion.rate;    motion.targetRate = targetRate;    motion.easeStartedAt = performance.now();    wake.current();  }, [paused]);}/** * The logo strip under the hero: a boxed row with a label cell at the left (its key phrase in blue) and the logos in * cells divided by rules, looping sideways. The loop coasts to a stop under the pointer, rests while off screen, and does * not run under reduced motion (the row scrolls by hand instead). Logos with a `quote` show it in a card that glides * from logo to logo. `edgePattern` fills the gutters beside the column (the site uses the hero's dot field). */function LogoStrip({  label,  logos,  edgePattern,  loopMs = LOOP_MS,  className,  ...props}: React.ComponentProps<"section"> & { label?: React.ReactNode; logos: StripLogo[]; edgePattern?: React.ReactNode; loopMs?: number }) {  const stageRef = React.useRef<HTMLDivElement>(null);  const stripRef = React.useRef<HTMLDivElement>(null);  const cardRef = React.useRef<HTMLDivElement>(null);  const quotedTile = React.useRef<HTMLElement | null>(null);  const closeTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null);  const [anchor, setAnchor] = React.useState<Anchor | null>(null);  const [placement, setPlacement] = React.useState<(Placement & { glide: boolean }) | null>(null);  const [stripHovered, setStripHovered] = React.useState(false);  const quotes = React.useMemo(() => logos.map((logo) => logo.quote), [logos]);  const cancelHide = React.useCallback(() => {    if (closeTimer.current) clearTimeout(closeTimer.current);    closeTimer.current = null;  }, []);  const scheduleHide = React.useCallback(    (delay = CLOSE_DELAY_MS) => {      cancelHide();      closeTimer.current = setTimeout(() => setAnchor(null), delay);    },    [cancelHide],  );  React.useEffect(() => cancelHide, [cancelHide]);  const measure = React.useCallback((tile: HTMLElement): Anchor | null => {    const stage = stageRef.current;    if (!stage) return null;    const index = Number(tile.closest<HTMLElement>("[data-logo-index]")?.dataset.logoIndex ?? -1);    if (index < 0) return null;    const s = stage.getBoundingClientRect();    const t = tile.getBoundingClientRect();    return { index, tileLeft: t.left - s.left, tileWidth: t.width, tileTop: t.top - s.top, tileBottom: t.bottom - s.top };  }, []);  const show = React.useCallback(    (tile: HTMLElement) => {      const next = measure(tile);      if (!next) return;      cancelHide();      quotedTile.current = tile;      setAnchor(next);    },    [cancelHide, measure],  );  // The row coasts a few pixels after the pointer lands, so the card follows the logo to where it came to rest.  const reanchor = React.useCallback(() => {    const tile = quotedTile.current;    if (tile) setAnchor((current) => (current ? (measure(tile) ?? current) : current));  }, [measure]);  useEasedMarquee(stripRef, stripHovered || anchor !== null, loopMs, reanchor);  // The card's height is known once the quote it is about to show is in flow, so it is placed before paint. Moving  // along the row glides it; opening it, or switching it to the other side, places it without a glide.  React.useLayoutEffect(() => {    const stage = stageRef.current;    const card = cardRef.current;    if (!anchor || !stage || !card) return;    const place = () => {      const next = placeCard(anchor, { width: card.offsetWidth, height: card.offsetHeight }, stage.getBoundingClientRect());      setPlacement((previous) => {        return { ...next, glide: previous !== null && previous.below === next.below };      });    };    place();    window.addEventListener("scroll", place, { passive: true });    window.addEventListener("resize", place);    return () => {      window.removeEventListener("scroll", place);      window.removeEventListener("resize", place);    };  }, [anchor]);  React.useEffect(() => {    if (!anchor) setPlacement(null);  }, [anchor]);  const open = anchor !== null && placement !== null;  const shownQuote = anchor ? quotes[anchor.index] : undefined;  return (    <section data-slot="logo-strip" data-quote-open={open || undefined} className={cn("relative w-full border-b border-rule bg-surface py-6 md:py-8", open && "z-20", className)} {...props}>      {edgePattern && (        <>          <div aria-hidden="true" className="absolute inset-y-0 left-0 hidden w-[max(5rem,calc((100vw-var(--ds-layout-column))/2))] lg:block">            {edgePattern}          </div>          <div aria-hidden="true" className="absolute inset-y-0 right-0 hidden w-[max(5rem,calc((100vw-var(--ds-layout-column))/2))] lg:block">            {edgePattern}          </div>        </>      )}      <div className="relative mx-auto w-full max-w-(--ds-frame-column) px-6 md:px-10 lg:px-12">        <div ref={stageRef} className="relative">          <div className="flex flex-col overflow-hidden rounded-card border border-rule bg-surface md:flex-row">            {label && (              <div className="flex shrink-0 items-center justify-center border-b border-rule px-6 py-4 md:w-76 md:justify-start md:border-r md:border-b-0 md:py-0">                <h2 className="text-center text-body leading-snug font-normal tracking-normal text-balance text-fg-secondary md:text-left">{label}</h2>              </div>            )}            {/* Two identical rows each slide their own width, so the loop is seamless. */}            <div              ref={stripRef}              onPointerEnter={(event) => {                if (event.pointerType !== "touch") setStripHovered(true);              }}              onPointerLeave={() => {                setStripHovered(false);                scheduleHide(0);              }}              className="relative flex min-w-0 flex-1 overflow-hidden motion-reduce:overflow-x-auto"            >              {[false, true].map((duplicate) => (                <div key={String(duplicate)} data-strip-row="" aria-hidden={duplicate || undefined} className={cn("flex shrink-0 will-change-transform", duplicate && "motion-reduce:hidden")}>                  {logos.map((logo, index) => (                    <div key={logo.name} data-logo-index={index} className="flex">                      <LogoTile logo={logo} duplicate={duplicate} onShow={show} onHide={scheduleHide} />                    </div>                  ))}                </div>              ))}            </div>          </div>          {/* The quote card (md up). Its padding on the logo's side is a bridge the pointer crosses without closing it. */}          {quotes.some(Boolean) && (            <div              ref={cardRef}              aria-hidden={!open}              onPointerEnter={cancelHide}              onPointerLeave={() => scheduleHide(0)}              style={placement ? { translate: `${placement.x}px ${placement.y}px`, transformOrigin: placement.below ? "50% 0%" : "50% 100%" } : undefined}              className={cn(                "absolute top-0 left-0 z-10 hidden w-85 ease-out before:absolute before:inset-x-0 before:h-3 motion-reduce:transition-none md:block",                placement?.below ? "before:bottom-full" : "before:top-full",                placement?.glide ? "transition-[translate,opacity,scale] duration-200" : "transition-[opacity,scale] duration-150",                open ? "visible scale-100 opacity-100" : "pointer-events-none invisible scale-97 opacity-0",              )}            >              <div className="overflow-hidden rounded-card border border-rule bg-surface shadow-raised">                {/* Every quote stays mounted: the one showing is in flow (the card takes its height), the rest fade out on top. */}                <div className="relative">                  {quotes.map((quote, index) =>                    quote ? (                      <div                        key={logos[index].name}                        aria-hidden={quote !== shownQuote}                        className={cn("transition-[opacity,visibility] duration-150 ease-out motion-reduce:transition-none", quote === shownQuote ? "visible relative opacity-100" : "invisible absolute inset-x-0 top-0 opacity-0")}                      >                        <QuoteCardContent quote={quote} />                      </div>                    ) : null,                  )}                </div>              </div>            </div>          )}        </div>      </div>    </section>  );}export { LogoStrip, type StripLogo };

Reference: src/components/sections/home/featured-logos.tsx.