# Case Study Section v2

> A case study with a headline above two columns. The left column has a rounded cover image. The right column has a summary and three results, each with a small SVG chart: a stepped adoption line, a burndown against a dashed plan, and a before-and-after stage timeline.

A shadcn/ui block from the [@uiception](https://uiception.com/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/uiception/case-study-section-v2).

- Page, with a live preview: https://shadcn.exchange/r/uiception/case-study-section-v2
- Screenshot: https://shadcn.exchange/shot/uiception/case-study-section-v2/light.png
- Registry item: https://uiception.com/r/case-study-section-v2.json
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/uiception/case-study-section-v2.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- Registry items: `https://uiception.com/r/block-media.json`

## Files

### `registry/new-york/blocks/case-study-section-v2/page.tsx`

```tsx
import CaseStudySectionV2 from "./components/case-study-section-v2"

export default function Page() {
  return <CaseStudySectionV2 />
}
```

### `registry/new-york/blocks/case-study-section-v2/components/case-study-section-v2.tsx`

```tsx
import Image from "next/image"

import { createBlockImage } from "@/lib/block-media"

import { caseStudy, metrics } from "../lib/config"
import { MetricChart } from "./metric-chart"

const blockImage = createBlockImage("case-study-section-v2")

export default function CaseStudySectionV2() {
  return (
    <section className="py-16 md:py-24">
      <div className="mx-auto max-w-6xl px-6">
        <h2 className="max-w-2xl text-3xl leading-[1.15] font-medium tracking-tight text-foreground md:text-4xl">
          {caseStudy.title}
        </h2>

        <div className="mt-10 grid gap-10 md:grid-cols-12 md:gap-12">
          <div className="relative aspect-4/3 overflow-hidden rounded-xl bg-muted md:col-span-7 md:aspect-auto md:min-h-96">
            <Image
              src={blockImage(caseStudy.imageFile)}
              alt={caseStudy.alt}
              fill
              unoptimized
              priority
              sizes="(max-width: 768px) 100vw, 640px"
              className="object-cover"
            />
          </div>

          <div className="flex flex-col md:col-span-5 md:justify-center">
            <p className="text-[0.9375rem] leading-relaxed text-muted-foreground">
              {caseStudy.summary}
            </p>

            <dl className="mt-8 divide-y divide-border">
              {metrics.map((metric) => (
                <div
                  key={metric.id}
                  className="flex items-center justify-between gap-6 py-4"
                >
                  <div className="flex min-w-0 flex-col-reverse gap-0.5">
                    <dt className="text-sm text-muted-foreground">
                      {metric.label}
                    </dt>
                    <dd className="text-xl font-medium tracking-tight text-foreground tabular-nums">
                      {metric.value}
                    </dd>
                  </div>
                  <div
                    aria-hidden
                    className="aspect-[160/56] w-32 shrink-0 lg:w-40"
                  >
                    <MetricChart chart={metric.chart} />
                  </div>
                </div>
              ))}
            </dl>
          </div>
        </div>
      </div>
    </section>
  )
}
```

### `registry/new-york/blocks/case-study-section-v2/components/metric-chart.tsx`

```tsx
import type { MetricChart as MetricChartData } from "../lib/config"

/** Every chart draws into the same 160×56 box so they line up in the list. */
const W = 160
const H = 56
const PLOT = { left: 2, right: W - 6, top: 8, bottom: 42 }
const TICK_Y = H - 3

export function MetricChart({ chart }: { chart: MetricChartData }) {
  return (
    <svg
      viewBox={`0 0 ${W} ${H}`}
      className="size-full overflow-visible text-[8px] tabular-nums"
    >
      {chart.type === "steps" && (
        <StepsChart values={chart.values} ticks={chart.ticks} />
      )}
      {chart.type === "burndown" && (
        <BurndownChart values={chart.values} ticks={chart.ticks} />
      )}
      {chart.type === "timeline" && (
        <TimelineChart rows={chart.rows} ticks={chart.ticks} />
      )}
    </svg>
  )
}

function scaleX(index: number, count: number) {
  return PLOT.left + (index / (count - 1)) * (PLOT.right - PLOT.left)
}

function scaleY(value: number, max: number) {
  return PLOT.bottom - (value / max) * (PLOT.bottom - PLOT.top)
}

function Gridlines() {
  return (
    <g className="stroke-border" strokeWidth={1}>
      {[PLOT.top, (PLOT.top + PLOT.bottom) / 2].map((y) => (
        <line
          key={y}
          x1={PLOT.left}
          x2={PLOT.right}
          y1={y}
          y2={y}
          strokeDasharray="2 3"
        />
      ))}
      <line x1={PLOT.left} x2={PLOT.right} y1={PLOT.bottom} y2={PLOT.bottom} />
    </g>
  )
}

function Ticks({ labels }: { labels: string[] }) {
  return (
    <g className="fill-muted-foreground">
      {labels.map((label, index) => (
        <text
          key={label}
          x={scaleX(index, labels.length)}
          y={TICK_Y}
          textAnchor={
            index === 0
              ? "start"
              : index === labels.length - 1
                ? "end"
                : "middle"
          }
        >
          {label}
        </text>
      ))}
    </g>
  )
}

function EndPoint({ x, y }: { x: number; y: number }) {
  return (
    <>
      <circle cx={x} cy={y} r={5} className="fill-foreground/10" />
      <circle
        cx={x}
        cy={y}
        r={2.5}
        className="fill-foreground stroke-background"
        strokeWidth={1.5}
      />
    </>
  )
}

function StepsChart({ values, ticks }: { values: number[]; ticks: string[] }) {
  const max = Math.max(...values)
  const points = values.map((value, index) => ({
    x: scaleX(index, values.length),
    y: scaleY(value, max),
  }))
  const line = points
    .map((point, index) =>
      index === 0 ? `M${point.x},${point.y}` : `H${point.x}V${point.y}`
    )
    .join("")
  const last = points[points.length - 1]

  return (
    <>
      <Gridlines />
      <path d={`${line}V${PLOT.bottom}H${PLOT.left}Z`} className="fill-muted" />
      <path
        d={line}
        fill="none"
        className="stroke-foreground"
        strokeWidth={1.5}
        strokeLinejoin="round"
      />
      {points.slice(0, -1).map((point, index) => (
        <circle
          key={index}
          cx={point.x}
          cy={point.y}
          r={1.25}
          className="fill-muted-foreground"
        />
      ))}
      <EndPoint x={last.x} y={last.y} />
      <Ticks labels={ticks} />
    </>
  )
}

function BurndownChart({
  values,
  ticks,
}: {
  values: number[]
  ticks: string[]
}) {
  const max = values[0]
  const points = values.map((value, index) => ({
    x: scaleX(index, values.length),
    y: scaleY(value, max),
  }))
  const line = points.map((point) => `${point.x},${point.y}`).join(" ")
  const last = points[points.length - 1]

  return (
    <>
      <Gridlines />
      <polygon
        points={`${PLOT.left},${PLOT.bottom} ${line} ${last.x},${PLOT.bottom}`}
        className="fill-muted"
      />
      <line
        x1={PLOT.left}
        y1={PLOT.top}
        x2={PLOT.right}
        y2={PLOT.bottom}
        className="stroke-muted-foreground"
        strokeWidth={1}
        strokeDasharray="3 2"
      />
      <polyline
        points={line}
        fill="none"
        className="stroke-foreground"
        strokeWidth={1.5}
        strokeLinejoin="round"
        strokeLinecap="round"
      />
      <EndPoint x={last.x} y={last.y} />
      <text
        x={PLOT.right}
        y={PLOT.top - 2}
        textAnchor="end"
        className="fill-muted-foreground"
      >
        Plan
      </text>
      <Ticks labels={ticks} />
    </>
  )
}

const TIMELINE = { labelWidth: 30, rowHeight: 9, rowGap: 7, segmentGap: 1.5 }

function TimelineChart({
  rows,
  ticks,
}: {
  rows: { label: string; stages: number[] }[]
  ticks: number[]
}) {
  const maxDays = Math.max(
    ...rows.map((row) => row.stages.reduce((sum, days) => sum + days, 0)),
    ...ticks
  )
  const x0 = TIMELINE.labelWidth
  const dayWidth = (PLOT.right - x0) / maxDays
  const rowsHeight =
    rows.length * TIMELINE.rowHeight + (rows.length - 1) * TIMELINE.rowGap
  const top = PLOT.top + (PLOT.bottom - PLOT.top - rowsHeight) / 2

  return (
    <>
      <g className="stroke-border" strokeWidth={1}>
        {ticks.map((day) => (
          <line
            key={day}
            x1={x0 + day * dayWidth}
            x2={x0 + day * dayWidth}
            y1={PLOT.top - 4}
            y2={PLOT.bottom}
            strokeDasharray={day === 0 ? undefined : "2 3"}
          />
        ))}
      </g>

      {rows.map((row, rowIndex) => {
        const y = top + rowIndex * (TIMELINE.rowHeight + TIMELINE.rowGap)
        const isCurrent = rowIndex === rows.length - 1
        const total = row.stages.reduce((sum, days) => sum + days, 0)
        const starts = row.stages.map((_, stageIndex) =>
          row.stages.slice(0, stageIndex).reduce((sum, days) => sum + days, 0)
        )

        return (
          <g key={row.label}>
            <text
              x={0}
              y={y + TIMELINE.rowHeight - 1.5}
              className={
                isCurrent ? "fill-foreground" : "fill-muted-foreground"
              }
            >
              {row.label}
            </text>
            {row.stages.map((days, stageIndex) => (
              <rect
                key={stageIndex}
                x={x0 + starts[stageIndex] * dayWidth}
                y={y}
                width={days * dayWidth - TIMELINE.segmentGap}
                height={TIMELINE.rowHeight}
                rx={1.5}
                className={
                  isCurrent
                    ? "fill-foreground"
                    : stageIndex % 2 === 0
                      ? "fill-muted-foreground/40"
                      : "fill-muted-foreground/25"
                }
              />
            ))}
            {isCurrent && (
              <text
                x={x0 + total * dayWidth + 3}
                y={y + TIMELINE.rowHeight - 1.5}
                className="fill-foreground"
              >
                {total}d
              </text>
            )}
          </g>
        )
      })}

      <g className="fill-muted-foreground">
        {ticks.map((day) => (
          <text
            key={day}
            x={x0 + day * dayWidth}
            y={TICK_Y}
            textAnchor={day === 0 ? "start" : "middle"}
          >
            {day === 0 ? "0" : `${day}d`}
          </text>
        ))}
      </g>
    </>
  )
}
```

### `registry/new-york/blocks/case-study-section-v2/lib/config.ts`

```ts
export const caseStudy = {
  title: "One identity system for Acme's twelve products.",
  summary:
    "Separate teams built Acme's products, and by last year they used four logo variants and nine type scales. We replaced them with one mark and a shared set of design tokens that every team now uses.",
  imageFile: "image-1.jpg",
  alt: "A brand mark on a soft gray background surrounded by paper shapes",
} as const

export type MetricChart =
  /** Cumulative count that steps up once per period. */
  | { type: "steps"; values: number[]; ticks: string[] }
  /** Remaining work per period against a straight-line plan to zero. */
  | { type: "burndown"; values: number[]; ticks: string[] }
  /** Stage durations in days, before and after, drawn as segmented bars. */
  | {
      type: "timeline"
      rows: { label: string; stages: number[] }[]
      ticks: number[]
    }

export type Metric = {
  id: string
  value: string
  label: string
  chart: MetricChart
}

export const metrics: Metric[] = [
  {
    id: "teams",
    value: "14",
    label: "Teams moved over in a year",
    chart: {
      type: "steps",
      values: [2, 3, 3, 5, 6, 8, 9, 11, 12, 14],
      ticks: ["Q1", "Q2", "Q3", "Q4"],
    },
  },
  {
    id: "assets",
    value: "340+",
    label: "Old brand assets retired in 12 weeks",
    chart: {
      type: "burndown",
      values: [352, 349, 341, 322, 297, 251, 196, 140, 88, 47, 24, 12],
      ticks: ["Wk 1", "Wk 12"],
    },
  },
  {
    id: "handoff",
    value: "3 days",
    label: "From design to code, down from 11",
    chart: {
      type: "timeline",
      rows: [
        { label: "Before", stages: [4, 3, 4] },
        { label: "Now", stages: [1, 1, 1] },
      ],
      ticks: [0, 5, 10],
    },
  },
]
```

### `public/images/blocks/case-study-section-v2/image-1.jpg.gitkeep`

```gitkeep
Place your asset here. Same base name, any supported format: jpg, jpeg, png, webp, avif, gif, or svg. Remove the .gitkeep extension from the filename.
```
