# Case Study Section

> Case study chapter with a sticky numbered label, styled prose, pull quotes, outcome metrics and figures that can span the label column.

Category: Work · Registry item: `@portfolio-kit/case-study-section` · [Live docs](https://www.parthjadhav.com/products/portfolio-kit/components/case-study-section) · [Registry JSON](https://www.parthjadhav.com/products/portfolio-kit/r/case-study-section.json)

## Installation

```bash
npx shadcn@latest add @portfolio-kit/case-study-section
```

Requires the `@portfolio-kit` registry in `components.json` — see [Installation](https://www.parthjadhav.com/products/portfolio-kit/docs/installation.md).

Adds `components/portfolio/case-study-section.tsx` and the kit items it uses: `media`.

npm dependencies: `class-variance-authority`.

## Usage

```tsx
import {
  CaseStudyFigure,
  CaseStudyMetric,
  CaseStudyMetrics,
  CaseStudyProse,
  CaseStudyPullQuote,
  CaseStudySection,
  CaseStudySectionContent,
  CaseStudySectionLabel,
} from "@/components/portfolio/case-study-section"

<CaseStudySection aria-labelledby="challenge">
  <CaseStudySectionLabel index={1} id="challenge">Challenge</CaseStudySectionLabel>
  <CaseStudySectionContent>
    <CaseStudyProse>
      <p>Eleven products had forked one component library into four buttons and six date pickers.</p>
    </CaseStudyProse>
    <CaseStudyFigure src="/work/audit.jpg" alt="The component audit" label="Fig. 1" caption="Every button we found." />
    <CaseStudyPullQuote author="Lena Ortiz" position="VP Product">It finally feels like one product.</CaseStudyPullQuote>
    <CaseStudyMetrics>
      <CaseStudyMetric value="−38%" label="Time to ship a screen" />
      <CaseStudyMetric value="100%" label="Focus-visible coverage" />
    </CaseStudyMetrics>
  </CaseStudySectionContent>
</CaseStudySection>
```

## Examples

### Demo

A chapter whose numbered label stays pinned while the prose (headings, lists, quotes, code) scrolls past.

```tsx
import {
  CaseStudyProse,
  CaseStudySection,
  CaseStudySectionContent,
  CaseStudySectionLabel,
} from "@/components/portfolio/case-study-section"
import { Container } from "@/components/portfolio/section"

export default function CaseStudySectionDemo() {
  return (
    <Container className="py-6 md:py-10">
      <CaseStudySection aria-labelledby="challenge">
        <CaseStudySectionLabel index={1} id="challenge">
          Challenge
        </CaseStudySectionLabel>
        <CaseStudySectionContent>
          <CaseStudyProse>
            <p>
              Halcyon had grown to eleven products in four years. Each team had forked the original component library,
              and by the time we arrived there were <strong>four buttons, six date pickers and no shared tokens</strong>.
              Shipping a new screen meant re-deciding every spacing value.
            </p>
            <p>
              Our brief was simple to state and hard to do: make the products feel like one family without freezing any
              team in place. We started with an audit — every screen, every colour, every <code>z-index</code>.
            </p>
            <h3>What we found</h3>
            <ul role="list">
              <li>312 unique colour values, 41 of them near-duplicates of the brand blue.</li>
              <li>Focus styles missing on 60% of interactive elements.</li>
              <li>
                A spacing scale that existed only in a <a href="#audit">Figma file</a> nobody opened.
              </li>
            </ul>
            <blockquote>The problem wasn’t the components. It was that nobody owned the decisions behind them.</blockquote>
            <p>From there we set three principles, in order of priority:</p>
            <ol role="list">
              <li>Tokens are an API — version them like one.</li>
              <li>Accessibility is a default, not a review step.</li>
              <li>Every component ships with docs, tests and a migration path.</li>
            </ol>
          </CaseStudyProse>
        </CaseStudySectionContent>
      </CaseStudySection>
    </Container>
  )
}
```

### Outcome

Close a case study with the numbers that moved and a pull quote from the client.

```tsx
import {
  CaseStudyMetric,
  CaseStudyMetrics,
  CaseStudyProse,
  CaseStudyPullQuote,
  CaseStudySection,
  CaseStudySectionContent,
  CaseStudySectionLabel,
} from "@/components/portfolio/case-study-section"
import { Container } from "@/components/portfolio/section"

const quote = {
  text: "Our design system finally feels like one thing — and the teams actually want to use it.",
  author: "Ines Carvalho",
  role: "VP Product, Halcyon Labs",
}

export default function CaseStudySectionOutcome() {
  return (
    <Container className="py-6 md:py-10">
      <CaseStudySection aria-labelledby="outcome">
        <CaseStudySectionLabel index={4} id="outcome">
          Outcome
        </CaseStudySectionLabel>
        <CaseStudySectionContent>
          <CaseStudyProse>
            <p>
              Six months after launch, every product runs on Lumen. The numbers below compare the quarter before
              migration to the quarter after.
            </p>
          </CaseStudyProse>
          <CaseStudyMetrics>
            <CaseStudyMetric value="+38%" label="Activation" detail="New workspaces, first week" />
            <CaseStudyMetric value="4×" label="Faster UI delivery" detail="Median screen, idea → prod" />
            <CaseStudyMetric value="−62%" label="CSS shipped" />
            <CaseStudyMetric value="100" label="Lighthouse a11y" />
          </CaseStudyMetrics>
          <CaseStudyPullQuote author={quote.author} position={quote.role}>
            {quote.text}
          </CaseStudyPullQuote>
        </CaseStudySectionContent>
      </CaseStudySection>
    </Container>
  )
}
```

### Figures

Captioned figures that fill the text column, or go `wide` to break out across the label column.

```tsx
import {
  CaseStudyFigure,
  CaseStudyProse,
  CaseStudySection,
  CaseStudySectionContent,
  CaseStudySectionLabel,
} from "@/components/portfolio/case-study-section"
import { GenerativeArt } from "@/components/portfolio/generative-art"
import { Container } from "@/components/portfolio/section"

export default function CaseStudySectionFigures() {
  return (
    <Container className="py-6 md:py-10">
      <CaseStudySection aria-labelledby="process">
        <CaseStudySectionLabel index={2} id="process">
          Process
        </CaseStudySectionLabel>
        <CaseStudySectionContent>
          <CaseStudyProse>
            <p>
              We prototyped in code from week one. Every token change rendered across all eleven products in a single
              preview deploy, which made debates short and concrete.
            </p>
          </CaseStudyProse>
          <CaseStudyFigure label="Fig. 1" caption="Token pipeline: one source, eleven themes, three platforms.">
            <GenerativeArt seed="lumen-pipeline" variant="bauhaus" label="Diagram of the token pipeline" />
          </CaseStudyFigure>
          <CaseStudyFigure
            size="wide"
            ratio="wide"
            label="Fig. 2"
            caption="The component explorer — every component, in every theme, side by side."
          >
            <GenerativeArt seed="lumen-components" variant="interface" hue={250} label="Wide screenshot of the component explorer" />
          </CaseStudyFigure>
        </CaseStudySectionContent>
      </CaseStudySection>
    </Container>
  )
}
```

### With Table of Contents

The `stacked` layout puts the label above the content, so chapters can sit beside a scrollspy table of contents.

```tsx
import {
  CaseStudyProse,
  CaseStudySection,
  CaseStudySectionContent,
  CaseStudySectionLabel,
} from "@/components/portfolio/case-study-section"
import { Container } from "@/components/portfolio/section"
import { TableOfContents } from "@/components/portfolio/table-of-contents"

const chapters = [
  {
    id: "context",
    title: "Context",
    body: "Tidewater operators watched forty turbines across six browser tabs. Alarms arrived by email, minutes after the fact, and nobody trusted the numbers on screen.",
  },
  {
    id: "approach",
    title: "Approach",
    body: "We shadowed three control-room shifts, then rebuilt the dashboard around a single question: what needs a human right now? Everything else became quiet by default.",
  },
  {
    id: "results",
    title: "Results",
    body: "Median time to acknowledge an alarm fell from nine minutes to under one, and the six tabs became one screen that operators leave open all day.",
  },
]

export default function CaseStudySectionWithTableOfContents() {
  return (
    <Container className="grid pb-[40vh] lg:grid-cols-[11rem_minmax(0,1fr)] lg:gap-x-16">
      <aside className="hidden lg:block">
        <TableOfContents
          items={chapters.map(({ id, title }) => ({ id, label: title }))}
          offset={24}
          className="sticky top-6 pt-14 md:pt-20"
        />
      </aside>
      <div className="min-w-0">
        {chapters.map((chapter, i) => (
          <CaseStudySection key={chapter.id} id={chapter.id} layout="stacked" aria-labelledby={`${chapter.id}-title`}>
            <CaseStudySectionLabel index={i + 1} id={`${chapter.id}-title`}>
              {chapter.title}
            </CaseStudySectionLabel>
            <CaseStudySectionContent>
              <CaseStudyProse>
                <p>{chapter.body}</p>
              </CaseStudyProse>
            </CaseStudySectionContent>
          </CaseStudySection>
        ))}
      </div>
    </Container>
  )
}
```

## API Reference

### CaseStudySection

A chapter. In the `split` layout the label column has a fixed width (`--cs-label`) so wide
figures can break out of the content column by exactly that much. The sticky label stops
6rem below the top of the viewport, clear of a typical fixed site header; raise it for a taller
header with `--cs-sticky-top`, e.g. `className="[--cs-sticky-top:8rem]"`.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `layout` | `"split" \| "stacked"` | `"split"` | `split`: a sticky numbered label beside the content from `md` up. `stacked`: label above the content, for pages with their own side rail (e.g. a table of contents). |

Also accepts every `section` prop.

### CaseStudySectionLabel

Label column: index + chapter name (rendered as a heading). Sticky in the `split` layout, where it
stays pinned below the site header (`--cs-sticky-top`) and stops at the end of its chapter.
`id` goes on the heading, so the section can point `aria-labelledby` at it.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `index` | `number \| string` | — | Chapter number: `1` → "01". |
| `as` | `"h2" \| "h3"` | `"h2"` | Heading level of the label. `h2` under a `CaseStudyTitle` (the page's `h1`); `h3` when chapters sit inside your own `h2` sections. Prose headings inside should go one level lower. |

Also accepts every `div` prop.

### CaseStudySectionContent

The chapter body: prose, figures, metrics and quotes, evenly spaced.

Also accepts every `div` prop.

### CaseStudyProse

Long-form copy. Style plain HTML (p, h3, h4, ul, ol, blockquote, a, code, strong, hr) without extra
classes. Lines are capped at about 70 characters (`34em`) for comfortable reading; long words and
URLs wrap instead of overflowing.

Also accepts every `div` prop.

### CaseStudyPullQuote

A large quote from a client or user, set in the heading font: a `<figure>` with the `<blockquote>` and its attribution in a `<figcaption>`.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `author` | `React.ReactNode` | — | Who said it. |
| `position` | `React.ReactNode` | — | The author's job title or position, shown after the name. |

Also accepts every `figure` prop.

### CaseStudyMetrics

Grid of outcome numbers. Every metric gets its own top hairline, so it reads as a ruled table at any column count.

Also accepts every `dl` prop.

### CaseStudyMetric

One outcome: a big number over what it measures.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` (required) | `React.ReactNode` | — | The number, e.g. "+38%". |
| `label` (required) | `React.ReactNode` | — | What it measures, e.g. "Activation". |
| `detail` | `React.ReactNode` | — | Optional context under the label. |

Also accepts every `div` prop.

### CaseStudyFigure

Image, video or artwork with a numbered caption. `size="wide"` also spans the label column.
`className` styles the `<figure>`; the remaining props (`src`, `alt`, `ratio`…) go to the media frame.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `video` | `string` | — | Video source. Plays muted and looped while on screen and the tab is visible. Under reduced motion, reduced data or Save-Data it doesn't start; it shows `poster` and controls instead. |
| `children` | `React.ReactNode` | — | Artwork (e.g. `<GenerativeArt />`) or overlays rendered inside the frame, on top of any image or video. |
| `ratio` | `"photo" \| "video" \| "square" \| "portrait" \| "wide" \| "tall" \| "auto"` | `"video"` | Aspect ratio of the frame: `photo` 4:3, `video` 16:9, `square`, `portrait` 4:5, `tall` 3:4, `wide` 21:9. `auto` sets no ratio: the image or video keeps its own shape (pass `width`/`height` in `imgProps` or `videoProps` to reserve the space before it loads), or size the frame yourself with `className`/`frameClassName` (`aspect-*`, `size-full`). Artwork alone has no size of its own. |
| `radius` | `"default" \| "none" \| "lg" \| "full"` | `"lg"` | Corner radius: `default` follows the theme, `lg` is larger, `full` makes a circle (use with `square`), `none` is square. |
| `bordered` | `boolean` | — | Draw a hairline border, for screenshots with white edges on a white page. |
| `src` | `string` | — | Image URL. Loads lazily and fades in from a blur once decoded (at once under reduced motion). |
| `alt` | `string` | — | Text alternative for the image or video. Describe what the media shows when it carries content; leave it empty (the default) when it's decorative or the caption or nearby text already says it. |
| `poster` | `string` | — | Poster frame shown before the video plays (and instead of it under reduced motion or data saving). |
| `priority` | `boolean` | — | Mark the image as the page's main, above-the-fold image (the LCP element): it loads eagerly with high fetch priority and shows at once, without the blur-up fade. |
| `zoom` | `boolean` | — | Scale the media when the frame (or any ancestor `.group`) is hovered. Off for reduced motion. |
| `frameClassName` | `string` | — | Class for the media frame, e.g. a per-breakpoint aspect ratio: `ratio="auto" frameClassName="aspect-[5/4] sm:aspect-video"`. |
| `imgProps` | `Omit<React.ComponentProps<"img">, "src" \| "alt">` | — | Extra props for the `<img>`: `sizes` and `srcSet` for responsive images, `width`/`height`… |
| `videoProps` | `Omit<React.ComponentProps<"video">, "src" \| "poster">` | — | Extra props for the `<video>` (`controls`, `preload`, tracks…). With `controls`, the visitor's play and pause win. |
| `size` | `"column" \| "wide"` | `"column"` | `column`: fills the content column. `wide`: also spans the label column from `md` up, for hero shots and wide mockups. |
| `caption` | `React.ReactNode` | — | Caption under the media. |
| `label` | `React.ReactNode` | — | Figure label rendered before the caption, e.g. "Fig. 2". |

Other props are forwarded to the root element.

## Used in templates

- [Case Study](https://www.parthjadhav.com/products/portfolio-kit/templates/case-study.md)

## Source

`components/portfolio/case-study-section.tsx`

```tsx
/**
 * @title Case Study Section
 * @description Case study chapter with a sticky numbered label, styled prose, pull quotes, outcome metrics and figures that can span the label column.
 * @category work
 * @usage
 * ```tsx
 * <CaseStudySection aria-labelledby="challenge">
 *   <CaseStudySectionLabel index={1} id="challenge">Challenge</CaseStudySectionLabel>
 *   <CaseStudySectionContent>
 *     <CaseStudyProse>
 *       <p>Eleven products had forked one component library into four buttons and six date pickers.</p>
 *     </CaseStudyProse>
 *     <CaseStudyFigure src="/work/audit.jpg" alt="The component audit" label="Fig. 1" caption="Every button we found." />
 *     <CaseStudyPullQuote author="Lena Ortiz" position="VP Product">It finally feels like one product.</CaseStudyPullQuote>
 *     <CaseStudyMetrics>
 *       <CaseStudyMetric value="−38%" label="Time to ship a screen" />
 *       <CaseStudyMetric value="100%" label="Focus-visible coverage" />
 *     </CaseStudyMetrics>
 *   </CaseStudySectionContent>
 * </CaseStudySection>
 * ```
 */
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"

import { cn } from "@/lib/utils"
import { Media, type MediaProps } from "@/components/portfolio/media"

type Layout = "split" | "stacked"

const CaseStudySectionContext = React.createContext<{ layout: Layout }>({ layout: "split" })

const caseStudySectionVariants = cva(
  "grid scroll-mt-24 grid-cols-1 gap-y-8 border-t py-14 [--cs-gap:0rem] [--cs-label:0rem] [--cs-sticky-top:6rem] md:py-20",
  {
    variants: {
      layout: {
        /** Sticky label column beside the content. */
        split:
          "md:grid-cols-[var(--cs-label)_minmax(0,1fr)] md:gap-x-(--cs-gap) md:[--cs-gap:3rem] md:[--cs-label:10rem] lg:[--cs-gap:4rem] lg:[--cs-label:14rem]",
        /** Label above the content at every size, for pages with their own side rail such as a table of contents. */
        stacked: "gap-y-6"
      }
    },
    defaultVariants: { layout: "split" }
  }
)

type CaseStudySectionProps = React.ComponentProps<"section"> &
  VariantProps<typeof caseStudySectionVariants> & {
    /** `split`: a sticky numbered label beside the content from `md` up. `stacked`: label above the content, for pages with their own side rail (e.g. a table of contents). */
    layout?: "split" | "stacked"
  }

/**
 * A chapter. In the `split` layout the label column has a fixed width (`--cs-label`) so wide
 * figures can break out of the content column by exactly that much. The sticky label stops
 * 6rem below the top of the viewport, clear of a typical fixed site header; raise it for a taller
 * header with `--cs-sticky-top`, e.g. `className="[--cs-sticky-top:8rem]"`.
 */
function CaseStudySection({ className, layout, ...props }: CaseStudySectionProps) {
  const value = React.useMemo(() => ({ layout: layout ?? "split" }), [layout])
  return (
    <CaseStudySectionContext.Provider value={value}>
      <section
        data-slot="case-study-section"
        data-layout={value.layout}
        className={cn(caseStudySectionVariants({ layout }), className)}
        {...props}
      />
    </CaseStudySectionContext.Provider>
  )
}

type CaseStudySectionLabelProps = React.ComponentProps<"div"> & {
  /** Chapter number: `1` → "01". */
  index?: number | string
  /** Heading level of the label. `h2` under a `CaseStudyTitle` (the page's `h1`); `h3` when chapters sit inside your own `h2` sections. Prose headings inside should go one level lower. */
  as?: "h2" | "h3"
}

/**
 * Label column: index + chapter name (rendered as a heading). Sticky in the `split` layout, where it
 * stays pinned below the site header (`--cs-sticky-top`) and stops at the end of its chapter.
 * `id` goes on the heading, so the section can point `aria-labelledby` at it.
 */
function CaseStudySectionLabel({
  className,
  index,
  as: Tag = "h2",
  id,
  children,
  ...props
}: CaseStudySectionLabelProps) {
  const { layout } = React.useContext(CaseStudySectionContext)
  return (
    <div
      data-slot="case-study-section-label"
      className={cn("flex flex-col gap-3 self-start", layout === "split" && "md:sticky md:top-(--cs-sticky-top)", className)}
      {...props}
    >
      {index !== undefined && (
        <span
          aria-hidden
          className="flex items-center gap-3 font-mono text-xs font-medium tracking-[0.16em] text-highlight-ink tabular-nums"
        >
          {typeof index === "number" ? String(index).padStart(2, "0") : index}
          <span className="h-px w-6 bg-border" />
        </span>
      )}
      <Tag
        id={id}
        data-slot="case-study-section-title"
        className="font-heading text-2xl leading-tight text-balance wrap-break-word hyphens-auto md:text-[1.625rem]"
      >
        {children}
      </Tag>
    </div>
  )
}

/** The chapter body: prose, figures, metrics and quotes, evenly spaced. */
function CaseStudySectionContent({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="case-study-section-content"
      className={cn("flex min-w-0 flex-col gap-10 md:gap-14", className)}
      {...props}
    />
  )
}

/**
 * Long-form copy. Style plain HTML (p, h3, h4, ul, ol, blockquote, a, code, strong, hr) without extra
 * classes. Lines are capped at about 70 characters (`34em`) for comfortable reading; long words and
 * URLs wrap instead of overflowing.
 */
function CaseStudyProse({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="case-study-prose"
      className={cn(
        "max-w-[34em] text-base leading-[1.75] text-pretty wrap-break-word text-foreground/80 md:text-[1.0625rem]",
        "[&>*+*]:mt-5",
        // Headings
        "[&_h3]:mt-12 [&_h3]:font-heading [&_h3]:text-xl [&_h3]:leading-snug [&_h3]:text-foreground md:[&_h3]:text-2xl",
        "[&_h4]:mt-8 [&_h4]:font-heading [&_h4]:text-lg [&_h4]:text-foreground",
        "[&_h3+*]:mt-3 [&_h4+*]:mt-2 [&>:first-child]:mt-0",
        // Inline
        "[&_strong]:font-medium [&_strong]:text-foreground [&_em]:italic",
        "[&_a]:rounded-sm [&_a]:font-medium [&_a]:text-foreground [&_a]:underline [&_a]:decoration-foreground/30 [&_a]:decoration-1 [&_a]:underline-offset-[0.2em] [&_a]:outline-none [&_a]:transition-[text-decoration-color,color] [&_a]:duration-200 [&_a:focus-visible]:ring-[3px] [&_a:focus-visible]:ring-ring/50 [&_a:hover]:text-highlight-ink [&_a:hover]:decoration-current",
        "[&_:not(pre)>code]:rounded-sm [&_:not(pre)>code]:border [&_:not(pre)>code]:bg-muted [&_:not(pre)>code]:px-1.5 [&_:not(pre)>code]:py-px [&_:not(pre)>code]:font-mono [&_:not(pre)>code]:text-[0.85em] [&_:not(pre)>code]:text-foreground",
        "[&_pre]:overflow-x-auto [&_pre]:rounded-lg [&_pre]:border [&_pre]:bg-muted/60 [&_pre]:p-4 [&_pre]:font-mono [&_pre]:text-[0.85em] [&_pre]:leading-relaxed [&_pre]:text-foreground",
        // Lists
        "[&_ul]:flex [&_ul]:flex-col [&_ul]:gap-2 [&_ul>li]:relative [&_ul>li]:pl-6 [&_ul>li]:before:absolute [&_ul>li]:before:top-[0.85em] [&_ul>li]:before:left-0 [&_ul>li]:before:h-px [&_ul>li]:before:w-3 [&_ul>li]:before:bg-highlight [&_ul>li]:before:content-['']",
        "[&_ol]:flex [&_ol]:list-none [&_ol]:flex-col [&_ol]:gap-2 [&_ol]:[counter-reset:cs] [&_ol>li]:relative [&_ol>li]:pl-8 [&_ol>li]:[counter-increment:cs] [&_ol>li]:before:absolute [&_ol>li]:before:top-[0.5em] [&_ol>li]:before:left-0 [&_ol>li]:before:font-mono [&_ol>li]:before:text-xs [&_ol>li]:before:text-highlight-ink [&_ol>li]:before:tabular-nums [&_ol>li]:before:content-[counter(cs,decimal-leading-zero)]",
        // Blocks
        "[&_blockquote]:border-l [&_blockquote]:border-highlight [&_blockquote]:pl-5 [&_blockquote]:font-heading [&_blockquote]:text-xl [&_blockquote]:leading-snug [&_blockquote]:text-foreground",
        "[&_hr]:my-10 [&_hr]:border-border",
        className
      )}
      {...props}
    />
  )
}

type CaseStudyPullQuoteProps = React.ComponentProps<"figure"> & {
  /** Who said it. */
  author?: React.ReactNode
  /** The author's job title or position, shown after the name. */
  position?: React.ReactNode
}

/** A large quote from a client or user, set in the heading font: a `<figure>` with the `<blockquote>` and its attribution in a `<figcaption>`. */
function CaseStudyPullQuote({ className, author, position, children, ...props }: CaseStudyPullQuoteProps) {
  return (
    <figure
      data-slot="case-study-pull-quote"
      className={cn("relative flex max-w-3xl flex-col gap-6 py-2", className)}
      {...props}
    >
      <span
        aria-hidden
        className="h-8 font-heading text-7xl leading-none text-highlight-ink select-none md:text-8xl md:h-10"
      >
        &ldquo;
      </span>
      <blockquote className="font-heading text-2xl leading-[1.2] text-balance wrap-break-word text-foreground sm:text-3xl md:text-4xl">
        {children}
      </blockquote>
      {(author || position) && (
        <figcaption className="flex items-center gap-3 text-sm">
          <span aria-hidden className="h-px w-8 bg-border" />
          <span className="flex flex-wrap items-baseline gap-x-2">
            {author && <span className="font-medium text-foreground">{author}</span>}
            {position && <span className="text-muted-foreground">{position}</span>}
          </span>
        </figcaption>
      )}
    </figure>
  )
}

/** Grid of outcome numbers. Every metric gets its own top hairline, so it reads as a ruled table at any column count. */
function CaseStudyMetrics({ className, ...props }: React.ComponentProps<"dl">) {
  return (
    <dl
      data-slot="case-study-metrics"
      className={cn(
        "grid grid-cols-2 gap-x-6 gap-y-8 sm:grid-cols-[repeat(auto-fit,minmax(9rem,1fr))] md:gap-x-8",
        className
      )}
      {...props}
    />
  )
}

type CaseStudyMetricProps = Omit<React.ComponentProps<"div">, "children"> & {
  /** The number, e.g. "+38%". */
  value: React.ReactNode
  /** What it measures, e.g. "Activation". */
  label: React.ReactNode
  /** Optional context under the label. */
  detail?: React.ReactNode
}

/** One outcome: a big number over what it measures. */
function CaseStudyMetric({ className, value, label, detail, ...props }: CaseStudyMetricProps) {
  return (
    <div
      data-slot="case-study-metric"
      className={cn("flex flex-col-reverse justify-end gap-2 border-t pt-4", className)}
      {...props}
    >
      <dt className="flex flex-col gap-1 text-sm leading-snug">
        <span className="font-medium text-foreground">{label}</span>
        {detail && <span className="text-muted-foreground">{detail}</span>}
      </dt>
      <dd className="font-heading text-4xl leading-none tabular-nums wrap-break-word md:text-5xl">{value}</dd>
    </div>
  )
}

const figureVariants = cva("flex min-w-0 flex-col gap-3", {
  variants: {
    size: {
      /** Fills the content column. */
      column: "",
      /** Also spans the label column (from `md` up). */
      wide: "md:-ml-[calc(var(--cs-label)+var(--cs-gap))]"
    }
  },
  defaultVariants: { size: "column" }
})

type CaseStudyFigureProps = Omit<MediaProps, "caption"> &
  VariantProps<typeof figureVariants> & {
    /** `column`: fills the content column. `wide`: also spans the label column from `md` up, for hero shots and wide mockups. */
    size?: "column" | "wide"
    /** Caption under the media. */
    caption?: React.ReactNode
    /** Figure label rendered before the caption, e.g. "Fig. 2". */
    label?: React.ReactNode
    /**
     * Class for the media frame, e.g. a per-breakpoint aspect ratio:
     * `ratio="auto" frameClassName="aspect-[5/4] sm:aspect-video"`.
     */
    frameClassName?: string
  }

/**
 * Image, video or artwork with a numbered caption. `size="wide"` also spans the label column.
 * `className` styles the `<figure>`; the remaining props (`src`, `alt`, `ratio`…) go to the media frame.
 */
function CaseStudyFigure({
  className,
  size,
  caption,
  label,
  ratio = "video",
  radius = "lg",
  frameClassName,
  ...props
}: CaseStudyFigureProps) {
  return (
    <figure
      data-slot="case-study-figure"
      data-size={size ?? "column"}
      className={cn(figureVariants({ size }), className)}
    >
      <Media ratio={ratio} radius={radius} className={frameClassName} {...props} />
      {(caption || label) && (
        <figcaption className="flex gap-3 font-mono text-xs leading-relaxed text-muted-foreground">
          {label && <span className="shrink-0 text-highlight-ink">{label}</span>}
          {caption && <span className="text-pretty">{caption}</span>}
        </figcaption>
      )}
    </figure>
  )
}

export {
  CaseStudySection,
  caseStudySectionVariants,
  CaseStudySectionLabel,
  CaseStudySectionContent,
  CaseStudyProse,
  CaseStudyPullQuote,
  CaseStudyMetrics,
  CaseStudyMetric,
  CaseStudyFigure,
  figureVariants as caseStudyFigureVariants
}
```
