HeroDesign system
Site

Hero

Site hero with white or blue backgrounds, title, description, actions, and an optional media panel.

npx context-ds add hero
On the site
Blue

Usage

Use it for

The first section of a site page, once. size="large" and surface="white" for the home page; the default title and surface="blue" for inner pages. One pill and one pill-outline button as actions (pill-on-brand on blue), the first in a HeroAction with its "No card required" note; the visual in HeroMedia, a HeroFrame or dot field as background.

Avoid

  • Bold titles
  • More than one accent phrase group
  • Left aligned hero text
  • A gray or gradient hero background
  • A second colored block beside the visual panel

Dependencies: ring-backdrop

Example

examples/hero.tsx
import { Button } from "@/components/ui/button";import { Hero, HeroAction, HeroFrame, HeroMedia, HeroMediaCard, HeroMediaTab, HeroPill } from "@/components/site/hero";import { ScrapeStory } from "@/components/visuals/stories/scrape-story";export function Site() {  return (    <Hero      size="large"      // The large title holds one line on wide screens; wrapTitle breaks it at its own line break to fit this frame.      wrapTitle      staggerIn      background={<HeroFrame />}      eyebrow={<HeroPill>Backed by Y Combinator</HeroPill>}      title={"The web as context\nfor your agents."}      accent={["context", "agents"]}      description="One API for agents to scrape, search, monitor, and collect data from the web."      actions={        <>          <HeroAction note="No card required">            <Button asChild variant="pill">              <a href="https://context.dev/signup">Start for free</a>            </Button>          </HeroAction>          <Button asChild variant="pill-outline">            <a href="https://docs.context.dev">Read the docs</a>          </Button>        </>      }      media={        <HeroMedia          size="small"          footer={            <HeroMediaTab className="justify-center text-caption text-on-brand">              <span>Markdown</span>              <span>HTML</span>              <span>Screenshots</span>            </HeroMediaTab>          }        >          <HeroMediaCard>            <div className="mx-auto w-full max-w-[22rem] p-4">              <ScrapeStory />            </div>          </HeroMediaCard>        </HeroMedia>      }    />  );}export function Blue() {  return (    <Hero      surface="blue"      eyebrow={<HeroPill>Backed by Y Combinator</HeroPill>}      pill={<HeroPill variant="outline">New: monitors</HeroPill>}      title="Website crawler {API}"      description="Map and crawl entire websites into clean Markdown your agents can read."      actions={        <>          <HeroAction note="No card required">            <Button asChild variant="pill">              <a href="https://context.dev/signup">Start for free</a>            </Button>          </HeroAction>          <Button asChild variant="pill-on-brand">            <a href="https://docs.context.dev">Read the docs</a>          </Button>        </>      }    />  );}

Source

components/site/hero.tsx
"use client";import * as React from "react";import { RingBackdrop } from "@/components/visuals/ring-backdrop";import { cn } from "@/lib/utils";type HeroSurface = "white" | "blue";// The two hero surfaces: the home page's white hero (its key phrase in blue, a rule along its foot) and the blue hero// of the other pages (white text, the title's key phrase plain white).const SURFACES: Record<HeroSurface, { section: string; title: string; accent: string; muted: string; frame: string }> = {  white: {    section: "bg-surface shadow-[inset_0_-1px_0_var(--ds-color-border-rule)]",    title: "text-fg",    accent: "text-fg-brand",    muted: "text-fg-muted",    frame: "text-rule",  },  blue: {    section: "bg-brand",    title: "text-on-brand",    accent: "text-on-brand",    muted: "text-on-brand/80",    frame: "text-on-brand/25",  },};const HeroContext = React.createContext<HeroSurface>("white");// Each piece's place in the entrance: it rises and fades in one step after the last. The site plays this at 360ms// with a blur; it is held to 300ms here, and reduced motion keeps only the fade (the content-in keyframes).function stagger(on: boolean, step: number): { className?: string; style?: React.CSSProperties } {  if (!on) return {};  return {    className: "animate-content-in [animation-duration:var(--ds-duration-slow)] [animation-fill-mode:backwards]",    style: { animationDelay: `${step * 60}ms` },  };}// Braces are a motif drawn with CSS, so the heading's text (what crawlers index) reads "The web context API" while// the page shows "{context}".function Braced({ phrase }: { phrase: string }) {  const word = /^\{(.+)\}$/.exec(phrase)?.[1];  if (!word) return <>{phrase}</>;  return (    <span data-brace-open="{" data-brace-close="}" className="before:content-[attr(data-brace-open)] after:content-[attr(data-brace-close)]">      {word}    </span>  );}// The title's key phrases in the surface's accent, same weight. Longest first, so overlapping phrases match greedily.function AccentedTitle({ title, accent, className }: { title: string; accent: string[]; className: string }) {  const phrases = accent.filter((phrase) => title.includes(phrase)).sort((a, b) => b.length - a.length);  if (phrases.length === 0) return <>{title}</>;  const pattern = new RegExp(phrases.map((phrase) => phrase.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|"), "g");  const parts: React.ReactNode[] = [];  let cursor = 0;  for (const match of title.matchAll(pattern)) {    parts.push(title.slice(cursor, match.index));    parts.push(<span key={`accent:${match.index}`} className={className}><Braced phrase={match[0]} /></span>);    cursor = match.index + match[0].length;  }  parts.push(title.slice(cursor));  return <>{parts}</>;}/** * The small line above the hero title. `plain` is the site's quiet "Backed by" line in muted text (a link underlines * on hover and keeps a 44px target); `outline` is a pill outlined in the title's color, for news ("New ..."). */function HeroPill({ variant = "plain", href, icon, className, children }: { variant?: "plain" | "outline"; href?: string; icon?: React.ReactNode; className?: string; children: React.ReactNode }) {  const surface = SURFACES[React.useContext(HeroContext)];  const Comp = href ? "a" : "span";  return (    <Comp      href={href}      data-slot="hero-pill"      className={cn(        "inline-flex max-w-full shrink-0 items-center justify-center gap-1.5 text-center text-body-sm leading-snug outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus-ring [&_img]:size-4.5 [&_img]:rounded-sm [&_svg]:size-4",        variant === "plain" && surface.muted,        variant === "plain" && href && "min-h-11 gap-2 underline-offset-4 hover:underline",        variant === "outline" && cn("gap-2 rounded-pill border border-current px-4 py-2", surface.title),        className,      )}    >      {icon}      {children}    </Comp>  );}/** One hero action with a caption under it (the site's "No card required" under its primary pill). */function HeroAction({ note, className, children }: { note?: React.ReactNode; className?: string; children: React.ReactNode }) {  const surface = SURFACES[React.useContext(HeroContext)];  return (    <div data-slot="hero-action" className={cn("flex flex-col items-center gap-2", className)}>      {children}      {note && <span className={cn("text-center text-caption leading-snug", surface.muted)}>{note}</span>}    </div>  );}/** * The site's hero, centered. An eyebrow line, then a pill and the title close together, the description, the actions * and the visual. `large` is the home hero's display title (one line on wide screens unless `wrapTitle`) with a larger * description; the default is the inner pages' title. With `media` the hero fills the first screen and centers its * content in it, so the next section never peeks in; set `--hero-offset` to the header's height when the hero sits * under it. It carries `data-ds-dots`, so the dot streams attach and run in from its edges. */function Hero({  surface = "white",  size = "default",  wrapTitle = false,  eyebrow,  pill,  title,  accent,  description,  actions,  media,  caption,  background,  staggerIn = false,  className,  contentClassName,  ...props}: Omit<React.ComponentProps<"section">, "title"> & {  /** `white` is the home hero; `blue` the other pages'. */  surface?: HeroSurface;  /** `large` is the home hero's display title. */  size?: "default" | "large";  /** Lets a large title wrap on wide screens, at its own line breaks or a narrower measure. */  wrapTitle?: boolean;  /** Above the title, a quiet line (a HeroPill). */  eyebrow?: React.ReactNode;  /** Right above the title, closer to it than anything else, a news pill. */  pill?: React.ReactNode;  title: React.ReactNode;  /** Phrases of a string title to show in the accent; wrap a phrase in braces ("{context}") to draw them. */  accent?: string | string[];  description?: React.ReactNode;  /** Pills, each optionally in a HeroAction with its caption. */  actions?: React.ReactNode;  /** The visual under the actions (a HeroMedia frame with a demo). */  media?: React.ReactNode;  /** One line under the visual ("or get an API key free."). */  caption?: React.ReactNode;  /** Layers behind the content (a dot field, a HeroFrame); it fills the section. */  background?: React.ReactNode;  /** Brings the pieces in one after another on load. */  staggerIn?: boolean;  /** Adjusts the inner content's spacing and height without changing the background. */  contentClassName?: string;}) {  const tone = SURFACES[surface];  const large = size === "large";  const accentList = typeof accent === "string" ? [accent] : (accent ?? (typeof title === "string" ? ["{API}", "{AI}"] : []));  const step = (n: number) => stagger(staggerIn, n);  const titleNeedsMeasure = typeof title === "string" ? !title.includes("\n") : true;  return (    <HeroContext.Provider value={surface}>      <section        id="hero"        data-slot="hero"        data-ds-dots=""        data-hero-surface={surface}        className={cn("relative isolate w-full overflow-hidden text-center", tone.section, className)}        {...props}      >        {background && (          <div aria-hidden="true" className="pointer-events-none absolute inset-0 -z-10">            {background}          </div>        )}        <div          className={cn(            "relative mx-auto flex w-full flex-col items-center overflow-hidden px-4 sm:px-6 lg:px-12",            media              ? "min-h-[max(calc(100svh-var(--hero-offset,0px)),calc(var(--spacing)*128))] justify-center gap-7 pt-8 pb-16 sm:pt-0 lg:gap-8"              : "min-h-[min(calc(100svh-var(--hero-offset,0px)),calc(var(--spacing)*250))] justify-center gap-8 py-10 sm:py-12 lg:py-16",            contentClassName,          )}        >          <div className={cn("relative z-10 mx-auto flex w-full shrink-0 flex-col items-center", media ? "gap-3" : "gap-6", large ? "max-w-240" : "max-w-200")}>            {eyebrow && (              <div {...step(0)} className={cn("flex justify-center", step(0).className)}>                {eyebrow}              </div>            )}            <div className="flex w-full flex-col items-center gap-3">              {pill}              <div className={cn("flex w-full flex-col items-center text-center", large ? "gap-3" : "gap-6")}>                {/* A large title never wraps on wide screens and can outgrow its column; the flex row keeps that overflow centered. */}                <div {...step(1)} className={cn("flex w-full justify-center", step(1).className)}>                  <h1                    className={cn(                      "w-full font-normal whitespace-pre-line text-balance",                      tone.title,                      large                        ? cn("text-hero leading-tight tracking-tight antialiased", wrapTitle ? titleNeedsMeasure && "max-w-(--ds-layout-text)" : "xl:w-max xl:shrink-0 xl:whitespace-nowrap")                        : "text-hero leading-heading tracking-normal sm:text-h1",                    )}                  >                    {typeof title === "string" ? <AccentedTitle title={title} accent={accentList} className={tone.accent} /> : title}                  </h1>                </div>                {description && (                  <div {...step(2)} className={cn("w-full", step(2).className)}>                    <p className={cn("text-balance", tone.muted, large ? "text-h5 leading-snug" : "text-body-lg leading-snug")}>{description}</p>                  </div>                )}              </div>            </div>          </div>          {actions && (            <div {...step(3)} className={cn("relative z-10 flex shrink-0 flex-wrap items-start justify-center gap-3", step(3).className)}>              {actions}            </div>          )}          {media && (            // min-h-0 down the chain lets the visual shrink to the space left, rather than pushing the hero past the screen.            <div className="relative z-10 mx-auto flex min-h-0 w-full max-w-225 flex-col items-center gap-3.5">              <div {...step(4)} className={cn("flex min-h-0 w-full flex-col items-center", step(4).className)}>                {media}              </div>              {caption && <p className={cn("text-center text-body-sm", tone.title)}>{caption}</p>}            </div>          )}        </div>      </section>    </HeroContext.Provider>  );}/** * The hero's visual frame: a brand blue panel set in a soft blue ring (the ring wraps the panel, so its outer edge * keeps the panel's radius), the one block of color on the page. `size` narrows it (`small` for a lighter first * screen, `compact` to the product monitors' width on phones). `backdrop` is the layer inset in the panel, the site's * faint white rings unless you pass another (or `null` for none); `footer` hangs a HeroMediaTab off its bottom edge. * Put the demo in a HeroMediaCard, so its corners nest inside the panel's. */function HeroMedia({  size = "default",  backdrop,  footer,  className,  panelClassName,  children,  ...props}: React.ComponentProps<"div"> & {  size?: "default" | "small" | "compact";  backdrop?: React.ReactNode;  footer?: React.ReactNode;  /** Classes for the blue panel; `className` sizes the ring around it. */  panelClassName?: string;}) {  return (    <div data-slot="hero-media" className="flex min-h-0 w-full flex-col items-center">      {/* The panel stacks above the tab, so the tab tucks under its bottom edge. */}      <div        className={cn(          "relative z-10 w-full max-w-232 rounded-[calc(var(--ds-radius-panel)-var(--ds-space-1-5))] bg-ring-tint p-2 sm:rounded-panel",          size === "small" && "sm:max-w-152",          size === "compact" && "max-w-76 sm:max-w-184",          className,        )}      >        <div          className={cn(            "relative flex min-h-0 w-full justify-center rounded-[calc(var(--ds-radius-panel)-var(--ds-space-1-5)-var(--ds-space-2))] bg-brand p-3 sm:rounded-[calc(var(--ds-radius-panel)-var(--ds-space-1))] sm:p-6 md:px-12 md:py-10",            panelClassName,          )}          {...props}        >          {backdrop !== null && (            <div aria-hidden="true" className="pointer-events-none absolute inset-2 overflow-hidden rounded-[calc(var(--spacing)*4.5)] text-white sm:rounded-[calc(var(--ds-radius-panel)-var(--ds-space-2))] md:inset-3">              {backdrop ?? <RingBackdrop className="inset-0 rounded-[inherit]" />}            </div>          )}          <div className="relative flex w-full justify-center">{children}</div>        </div>      </div>      {footer}    </div>  );}/** * The demo inside a HeroMedia panel: a white card in a solid blue-80 ring, each corner a step inside the one around it * (panel 36px, ring 28px, card 20px), as the site's quick demo sits in its panel. The ring overhangs by its own width, * so the card keeps the full content width. */function HeroMediaCard({ className, cardClassName, children }: { className?: string; cardClassName?: string; children: React.ReactNode }) {  return (    <div data-slot="hero-media-card" className={cn("-m-2 w-[calc(100%+var(--ds-space-4))] max-w-212 rounded-[calc(var(--ds-radius-panel)-var(--ds-space-3))] bg-blue-80 p-2", className)}>      <div className={cn("relative flex w-full flex-col overflow-hidden rounded-[calc(var(--ds-radius-panel)-var(--ds-space-5))] bg-surface", cardClassName)}>{children}</div>    </div>  );}// The tab's top corners curve out of the panel's bottom edge: each is a glass square with a quarter circle cut away.const GLASS = "bg-white/16 backdrop-blur-sm";const cornerMask = (side: "0" | "100%"): React.CSSProperties => ({  maskImage: `radial-gradient(circle at ${side} 100%, transparent var(--ds-space-3), var(--ds-color-black) var(--ds-space-3))`,  WebkitMaskImage: `radial-gradient(circle at ${side} 100%, transparent var(--ds-space-3), var(--ds-color-black) var(--ds-space-3))`,});/** A glass tab tucked under the HeroMedia panel (a row of proof points or a status line). */function HeroMediaTab({ className, children, ...props }: React.ComponentProps<"div">) {  return (    <div      data-slot="hero-media-tab"      className={cn(        "relative -mt-px flex w-[calc(min(100%,calc(var(--spacing)*208))-var(--ds-space-10))] items-center gap-4 rounded-b-card border border-t-0 border-white/7 px-4 py-1",        GLASS,        className,      )}      {...props}    >      <span aria-hidden="true" className={cn("absolute top-0 -left-3 size-3", GLASS)} style={cornerMask("0")} />      <span aria-hidden="true" className={cn("absolute top-0 -right-3 size-3", GLASS)} style={cornerMask("100%")} />      {children}    </div>  );}/** * A hairline construction frame behind the hero: a top and a bottom edge across the content width, no verticals, from * the md breakpoint up. Put it in the hero's `background`; it takes the surface's line color. */function HeroFrame({ surface = "white", className }: { surface?: HeroSurface; className?: string }) {  return (    <div aria-hidden="true" data-slot="hero-frame" className={cn("pointer-events-none absolute inset-0 hidden overflow-hidden md:block", SURFACES[surface].frame, className)}>      <div className="absolute inset-y-0 left-1/2 w-[min(var(--ds-layout-column-wide),100%-var(--ds-space-12))] -translate-x-1/2">        <div className="absolute inset-x-0 top-(--hero-frame-top,var(--ds-space-3)) h-px bg-current" />        <div className="absolute inset-x-0 bottom-0 h-px bg-current" />      </div>    </div>  );}export { Hero, HeroAction, HeroFrame, HeroMedia, HeroMediaCard, HeroMediaTab, HeroPill };

Reference: src/components/sections/shared/hero.tsx; src/components/sections/home/homepage-hero.tsx; src/components/sections/shared/hero-frame.tsx; src/components/sections/shared/demo-footer-tab.tsx; src/components/sections/shared/brand-outline-pill.ts.