Skip to content
DocsSupportPricing
Roadmap (has updates coming soon)XFigma3.4K
Overview
  • Introduction
  • Get Started
  • License Setup
  • Styling
  • Registry
  • MCP Server
  • Agent Skills
  • llms.txt
  • RTL
  • Changelog
MCP Server
  • Claude
  • CodexCodexCodex
  • Cursor
  • Grok
  • Conductor
  • v0
  • Lovable
  • Replit
  • Bolt
  • OpenCode
  • VS Code
  • GitHub Copilot
  • Kilo Code
  • Zed
  • Antigravity
Components
  • Alert
  • Autocomplete
  • Badge
  • CascaderNew component for nested multi-level selection
  • Code BlockNew component for rendering code with streaming and diffs
  • Data GridRebuilt Data Grid with TanStack Table v9
  • Date Selector
  • Event Calendar
  • File Upload
  • FiltersRebuilt around advanced filters support
  • Frame
  • GanttAs-built baselines, finish-to-start dependency arrows, and milestones
  • Icon Stack
  • Icon Tile
  • Kanban
  • Number Field
  • Phone Input
  • Rating
  • Scrollspy
  • Sortable
  • Stepper
  • Timeline
  • Tree

Application

  • App Shell
  • Auth
  • Card
  • Chart
  • Dashboard
  • Dialog
  • Empty State
  • Event Calendar
  • Form
  • GanttUpdated 4 blocks
  • Kanban Board
  • List
  • Navbar
  • Onboarding
  • Profile
  • Schedule
  • Settings
  • Sheet
  • Stats
  • Timeline
  • Wizard

Solutions

  • Agents
  • AI Ops
  • Analytics
  • Billing
  • Bookings
  • CRM
  • FilesAdded a new blocks
  • Inventory
  • Users

Templates

  • E-commerce
  • SaaS
  • Dashboard
  • Landing
  • All templates

eCommerce

  • Category Card
  • Checkout
  • Comparison
  • Coupon
  • Filter Sidebar
  • Product Card
  • Product Detail
  • Product Grid
  • Receipt
  • Review
  • Shopping Cart
  • Wishlist

Data Grid

  • Base
  • Columns
  • Drag & Drop
  • Editing
  • Expansion
  • Filtering
  • Grouping
  • Virtualization

Marketing

  • Blog
  • Contact
  • CTA
  • FAQ
  • HeroAddeed new 16 hero blocks

Resources

  • Components
  • Blocks
  • Icons
  • MCP for Agents
  • Docs
  • Support
  • Pricing
  • Roadmap(has updates coming soon)
  • AffiliateSoon

Legal

  • Privacy Policy
  • Terms & Conditions
  • License
  • Refunds
  • Cookies

© 2026 ReUI. All rights reserved.

3.4K

Shadcn Code Block

PreviousNext

Custom Shadcn Code Block for React and Tailwind CSS. A shadcn code block with Shiki syntax highlighting, streaming, diffs, code folding and per-line interaction for AI chat and agent UIs.

Base UIRadix UI

Installation

pnpm dlx shadcn@latest add @reui/code-block

The only npm dependency is shiki. Grammars and themes load lazily, one chunk per language, and nothing loads at all when you pass pre-highlighted lines or turn highlighting off.

Usage

import {
  CodeBlock,
  CodeBlockCopyButton,
  CodeBlockHeader,
  CodeBlockLanguage,
  CodeBlockTitle,
} from "@/components/reui/code-block/code-block"

Shadcn Code Block Free Components

Browse 27 production-ready Shadcn Code Block components for dashboards, forms, and product UI. These examples use Base UI primitives from @base-ui/react and stay fully compatible with Shadcn Create so radius, color, and typography match your configured theme.

Browse all 27 Shadcn Code Block components for copy-ready layouts, dashboards, and forms built with Tailwind CSS in the ReUI library.

CascaderData Grid

On This Page

InstallationUsageAnatomyFramingScrollingServer renderingStreaming and AI SDKMarkdown code fencesBundle and CSPAdding a languageThemingDark ThemeExamplesPinned copy buttonDiffNested code foldingFix with AI on the failing lineReference lines in chatUnified patch reviewThe restAPI ReferenceCodeBlockCodeBlockContentCodeBlockCopyButtonCodeBlockDownloadButtonCodeBlockLineActionsCodeBlockExpandButtonuseCodeBlockConfiguseCodeBlockFoldinguseCodeBlockSelectionHelpersData attributesKeyboardAccessibility
<CodeBlock code={code} language="tsx" />

That is a complete block. Everything else is composition:

<CodeBlock code={code} language="tsx" showLineNumbers>
  <CodeBlockHeader>
    <CodeBlockTitle>use-totals.ts</CodeBlockTitle>
    <CodeBlockLanguage />
    <CodeBlockCopyButton className="ml-auto" />
  </CodeBlockHeader>
</CodeBlock>

Anatomy

CodeBlock renders the code surface itself; with the default surface, children are chrome (a header, a copy button, an expand control) and their order never matters. Composing CodeBlockContent makes the surface one of the children, and from then on order is DOM order.

<CodeBlock>
  <CodeBlockHeader>
    <CodeBlockTitle />
    <CodeBlockLanguage />
    <CodeBlockWrapToggle />
    <CodeBlockCopyButton />
  </CodeBlockHeader>
  <CodeBlockContent /> {/* optional: compose inside your own ScrollArea */}
  <CodeBlockExpandButton />
  <CodeBlockLineActions>{({ line }) => null}</CodeBlockLineActions>
</CodeBlock>

A CodeBlockCopyButton inside a header is a normal flex child. Outside one it pins itself over the surface and stays put under both vertical and horizontal scrolling.

Framing

The default variant draws a bordered surface. Use variant="ghost" when something else already provides the chrome, so you do not get a doubled border. Ghost keeps the block's own padding; a flush fit is the container's job (p-0 on the panel, as the framed examples do).

<Card>
  <CardContent>
    <CodeBlock code={code} language="tsx" variant="ghost" />
  </CardContent>
</Card>
"use client"

import {
  CodeBlock,
  CodeBlockCopyButton,
} from "@/components/reui/code-block/code-block"
import {
  Frame,
  FrameHeader,
  FramePanel,
  FrameTitle,
} from "@/components/reui/frame"

import {
  Tabs,
  TabsContent,
  TabsList,
  TabsTrigger,
} from "@/components/ui/tabs"

const samples = [
  {
    file: "greeting.tsx",
    language: "tsx",
    code: `export function Greeting({ name }: { name: string }) {
  return <p>Hello {name}</p>
}`,
  },
  {
    file: "greeting.py",
    language: "python",
    code: `def greeting(name: str) -> str:
    return f"Hello {name}"`,
  },
  {
    file: "greeting.sql",
    language: "sql",
    code: `select id, email
from users
where created_at > now() - interval '7 days';`,
  },
  {
    file: "greeting.sh",
    language: "bash",
    code: `curl -s https://api.example.com/v1/users \\
  -H "Authorization: Bearer $TOKEN"`,
  },
]

/*
 * One grammar chunk loads per selected language, so switching files is also a
 * lazy-loading demo.
 *
 * A dense frame is what makes this read as one editor: the panel meets the
 * header with no gap or inset, and the ghost block inside contributes no
 * second border. The tabs live in the header, so the blocks stay headerless
 * and the copy button pins itself over the code.
 */
export function Pattern() {
  return (
    <Tabs defaultValue={samples[0].file} className="w-full max-w-2xl">
      <Frame dense spacing="sm">
        <FrameHeader className="flex-row items-center gap-2">
          <FrameTitle>Examples</FrameTitle>
          <TabsList className="ml-auto bg-transparent">
            {samples.map((sample) => (
              <TabsTrigger key={sample.file} value={sample.file}>
                {sample.file}
              </TabsTrigger>
            ))}
          </TabsList>
        </FrameHeader>

        <FramePanel className="p-0!">
          {samples.map((sample) => (
            <TabsContent key={sample.file} value={sample.file}>
              <CodeBlock
                code={sample.code}
                language={sample.language}
                variant="ghost"
                showLineNumbers
              >
                <CodeBlockCopyButton
                  variant="outline"
                  size="icon-xs"
                  className="bg-card hover:bg-muted"
                />
              </CodeBlock>
            </TabsContent>
          ))}
        </FramePanel>
      </Frame>
    </Tabs>
  )
}

Scrolling

CodeBlockContent is the code surface as a composable part: place it inside your own scroll container and the block stops scrolling internally - the ancestor owns both axes.

By default the block scrolls with a plain overflow: auto container of its own - no ScrollArea dependency, native thin scrollbars, and maxLines caps it.

To own the scrolling yourself, compose CodeBlockContent inside your own scroll container. The ancestor takes BOTH axes - the surface scrolls nothing itself - and a header above the area never scrolls away. The sticky line numbers anchor to the ancestor's viewport, and stick-to-bottom during streaming follows it automatically. Pass a ScrollBar orientation="horizontal" child for the horizontal bar; Base UI positions it against the scroll area's root, so it stays pinned even though it sits among the children.

<CodeBlock code={code} language="tsx" showLineNumbers>
  <CodeBlockHeader>
    <CodeBlockTitle>use-totals.ts</CodeBlockTitle>
    <CodeBlockCopyButton className="ml-auto" />
  </CodeBlockHeader>
  <ScrollArea className="rounded-[inherit] **:data-[slot=scroll-area-viewport]:max-h-72">
    <CodeBlockContent />
    <ScrollBar orientation="horizontal" />
  </ScrollArea>
</CodeBlock>

Use max-h-*, not a fixed height, so a short file does not leave dead space, and put it on the ScrollArea VIEWPORT (as above): the viewport is height-100% of the root, and a percentage against a max-height-only parent resolves to auto, so a root-level cap clips without ever scrolling. maxLines still marks the block collapsible, so CodeBlockExpandButton works either way; only the height cap itself belongs to the built-in viewport. To collapse a composed surface, read expanded from useCodeBlockConfig and cap your own ScrollArea, as the expand example does. CodeBlockContent must appear in the JSX you pass to CodeBlock - any depth, any wrapper, your own components included. It is invisible only when a child component CONSTRUCTS it internally instead of receiving it as children, and development logs an error if that happens.

import {
  CodeBlock,
  CodeBlockContent,
  CodeBlockCopyButton,
  CodeBlockHeader,
  CodeBlockTitle,
} from "@/components/reui/code-block/code-block"

import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"

const code = `export function useTheme() {
  const [theme, setTheme] = useState<"light" | "dark">("light")
  const toggle = () => setTheme((value) => (value === "light" ? "dark" : "light"))
  return { theme, toggle }
}`

/*
 * The "css-variables" theme emits var(--code-token-*) references instead of
 * hex colors, so the palette below IS the syntax theme: swap these lines for
 * your design tokens and the highlighting follows your brand in both modes.
 */
const paletteClass = [
  "[--code-token-keyword:var(--color-rose-600)]",
  "[--code-token-function:var(--color-violet-600)]",
  "[--code-token-string:var(--color-emerald-600)]",
  "[--code-token-string-expression:var(--color-emerald-600)]",
  "[--code-token-constant:var(--color-sky-600)]",
  "[--code-token-parameter:var(--color-amber-600)]",
  "[--code-token-comment:var(--muted-foreground)]",
  "[--code-token-punctuation:var(--foreground)]",
  "[--code-foreground:var(--foreground)]",
  "dark:[--code-token-keyword:var(--color-rose-400)]",
  "dark:[--code-token-function:var(--color-violet-400)]",
  "dark:[--code-token-string:var(--color-emerald-400)]",
  "dark:[--code-token-string-expression:var(--color-emerald-400)]",
  "dark:[--code-token-constant:var(--color-sky-400)]",
  "dark:[--code-token-parameter:var(--color-amber-400)]",
].join(" ")

export function Pattern() {
  return (
    <CodeBlock
      code={code}
      language="typescript"
      showLineNumbers
      themes={{ light: "css-variables", dark: "css-variables" }}
      className={"w-full max-w-2xl " + paletteClass}
    >
      <CodeBlockHeader>
        <CodeBlockTitle>use-theme.ts</CodeBlockTitle>
        <div className="ml-auto flex items-center gap-1.5">
          <span className="text-muted-foreground text-xs">
            Colors from design tokens
          </span>
          <CodeBlockCopyButton />
        </div>
      </CodeBlockHeader>

      {/* The ScrollArea composes INSIDE the block, so the block's own border
          and per-style radius stay the container, the header above never
          scrolls, and `max-h` means the area grows to the content instead of
          stretching past a short file. */}
      <ScrollArea className="rounded-[inherit] **:data-[slot=scroll-area-viewport]:max-h-72">
        <CodeBlockContent />
        <ScrollBar orientation="horizontal" />
      </ScrollArea>
    </CodeBlock>
  )
}

The inverse composition also works - default-mode blocks that size to content, listed inside one outer ScrollArea that scrolls the whole set - and ships as a changed-file list on the Code Block components page.

Server rendering

highlightCode lives in its own module with no "use client" directive, so a server component can call it. The result is plain JSON, so it crosses the boundary as data and the browser never downloads a grammar.

import { CodeBlock } from "@/components/reui/code-block/code-block"
import { highlightCode } from "@/components/reui/code-block/code-block-highlight"
 
export default async function Page() {
  const lines = await highlightCode(source, { language: "tsx" })
 
  return (
    <CodeBlock lines={lines} showLineNumbers>
      <CodeBlockHeader>
        <CodeBlockCopyButton value={source} />
      </CodeBlockHeader>
    </CodeBlock>
  )
}

The same shape works without React Server Components. In Remix or TanStack Start, return lines from a loader and pass them to the component.

export async function loader() {
  return { lines: await highlightCode(source, { language: "tsx" }) }
}

lines is a plain array, so any highlighter can produce it. If you already use Prism or highlight.js, map its output to CodeBlockLine[] and the component never loads shiki.

Streaming and AI SDK

Pass a code string that grows. The component re-tokenizes on a deferred value, and the highlighter returns the same line objects for lines that did not change, so an appended chunk re-renders one line rather than the whole file. Lines past what the highlighter has caught up to render as plain text, so the newest token is visible immediately.

<CodeBlock code={partial} language="tsx" streaming={status === "streaming"} />

streaming is presentational: it shows an underline caret, sticks the viewport to the bottom until the reader scrolls up, sets aria-busy, and turns on motion. New lines slide in once; tokens deliberately do not animate, because the highlighter runs a chunk behind the stream and replaces plain lines with tokenized ones - colour arriving instantly reads as highlighting, while a mount-keyed fade re-flashed lines that were already readable. The animation is compositor-only and honours prefers-reduced-motion. Incremental rendering is always memoized, so forgetting the prop costs correctness nothing.

"use client"

import { useEffect, useState } from "react"
import { Badge } from "@/components/reui/badge"
import {
  CodeBlock,
  CodeBlockContent,
  CodeBlockCopyButton,
  CodeBlockHeader,
  CodeBlockLanguage,
  CodeBlockTitle,
} from "@/components/reui/code-block/code-block"

import { Button } from "@/components/ui/button"
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"

/*
 * The sample deliberately omits its import statements: the registry verifier
 * scans raw file text for import shapes and would read them as real package
 * dependencies of the example, which then fails `registry:verify`.
 */
const target = `export async function POST(request: Request) {
  const { messages } = await request.json()

  const result = streamText({
    model: openai("gpt-5"),
    system: "You are a concise assistant.",
    messages,
    temperature: 0.2,
    maxOutputTokens: 1024,
  })

  result.usage.then((usage) => {
    metrics.record("chat.tokens", usage.totalTokens)
  })

  return result.toUIMessageStreamResponse()
}`

export function Pattern() {
  const [length, setLength] = useState(0)

  useEffect(() => {
    if (length >= target.length) return
    const id = window.setTimeout(() => setLength((value) => value + 3), 24)
    return () => window.clearTimeout(id)
  }, [length])

  const done = length >= target.length

  return (
    <div className="flex w-full max-w-2xl flex-col gap-3">
      <CodeBlock
        code={target.slice(0, length)}
        language="typescript"
        showLineNumbers
        streaming={!done}
      >
        <CodeBlockHeader>
          <CodeBlockTitle>app/api/chat/route.ts</CodeBlockTitle>
          <CodeBlockLanguage />
          <div className="ml-auto flex items-center gap-1.5">
            <Badge variant={done ? "success-light" : "info-light"}>
              {done ? "Complete" : "Generating"}
            </Badge>
            <CodeBlockCopyButton />
          </div>
        </CodeBlockHeader>

        {/* The consumer's ScrollArea owns the scroll, and stick-to-bottom
            follows it: the primitive resolves the nearest scrolling ancestor
            per chunk, so the caret stays in view exactly as it does with the
            built-in viewport. */}
        <ScrollArea className="rounded-[inherit] **:data-[slot=scroll-area-viewport]:max-h-60">
          <CodeBlockContent />
          <ScrollBar orientation="horizontal" />
        </ScrollArea>
      </CodeBlock>

      <Button
        variant="outline"
        size="sm"
        className="self-start"
        onClick={() => setLength(0)}
      >
        Replay stream
      </Button>
    </div>
  )
}

Markdown code fences

Most AI chat UIs do not hold a raw code string. They hold a markdown message with fenced blocks in it. Two helpers cover both routes.

Rendering a message yourself, markdownFences splits it into prose and code and reports a fence whose closing delimiter has not streamed in yet:

{
  markdownFences(message.text).map((part, index) =>
    part.type === "text" ? (
      <p key={index}>{part.content}</p>
    ) : (
      <CodeBlock
        key={index}
        code={part.content}
        language={part.language}
        streaming={part.open}
      >
        <CodeBlockCopyButton />
      </CodeBlock>
    )
  )
}

Using react-markdown or Streamdown, markdownCodeProps reads the language and the source out of the props given to pre:

function MarkdownPre(props: React.ComponentProps<"pre">) {
  const { code, language } = markdownCodeProps(props)
  return (
    <CodeBlock code={code} language={language}>
      <CodeBlockCopyButton />
    </CodeBlock>
  )
}
 
;<ReactMarkdown components={{ pre: MarkdownPre }}>{message.text}</ReactMarkdown>

An unknown language and an unterminated fence both render as plain text rather than throwing, which is what a half-streamed message always looks like.

"use client"

import { useEffect, useState } from "react"
import {
  CodeBlock,
  CodeBlockCopyButton,
  markdownFences,
} from "@/components/reui/code-block/code-block"
import {
  Frame,
  FrameDescription,
  FrameHeader,
  FramePanel,
  FrameTitle,
} from "@/components/reui/frame"

import {
  Avatar,
  AvatarFallback,
  AvatarImage,
} from "@/components/ui/avatar"

const reply = `Use the streaming helper, then render it:

\`\`\`tsx
const { messages } = useChat({ api: "/api/chat" })
\`\`\`

That keeps the transcript in sync.`

/*
 * `markdownFences` flags a fence whose closing delimiter has not arrived as
 * `open`, which is what lets the transcript render the partial block mid
 * stream instead of dropping it until the closer lands.
 */
export function Pattern() {
  const [length, setLength] = useState(0)

  useEffect(() => {
    if (length >= reply.length) return
    const id = window.setTimeout(() => setLength((value) => value + 4), 30)
    return () => window.clearTimeout(id)
  }, [length])

  const streamed = reply.slice(0, length)
  const streaming = length < reply.length

  return (
    <Frame dense className="w-full max-w-2xl">
      <FrameHeader>
        <FrameTitle>Assistant</FrameTitle>
        <FrameDescription>
          Generated responses may contain mistakes.
        </FrameDescription>
      </FrameHeader>

      <FramePanel className="flex flex-col gap-4">
        <div className="flex gap-3">
          <Avatar className="size-7 shrink-0 rounded-full">
            <AvatarImage
              src="https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=96&h=96&dpr=2&q=80"
              alt="Mira Stone"
            />
            <AvatarFallback className="text-[10px] font-medium">
              MS
            </AvatarFallback>
          </Avatar>
          <p className="pt-1 text-sm">How do I stream chat messages?</p>
        </div>

        <div className="flex gap-3">
          <Avatar className="size-7 shrink-0 rounded-full">
            <AvatarFallback className="bg-muted text-muted-foreground text-[10px] font-medium">
              AI
            </AvatarFallback>
          </Avatar>
          <div className="flex min-w-0 flex-1 flex-col gap-2 pt-1">
            {markdownFences(streamed).map((part, index) =>
              part.type === "text" ? (
                <p key={index} className="text-sm whitespace-pre-wrap">
                  {part.content}
                </p>
              ) : (
                <CodeBlock
                  key={index}
                  code={part.content}
                  language={part.language}
                  streaming={streaming && part.open}
                >
                  <CodeBlockCopyButton />
                </CodeBlock>
              )
            )}
          </div>
        </div>
      </FramePanel>
    </Frame>
  )
}

Bundle and CSP

The highlighter uses shiki's JavaScript regex engine, not oniguruma. There is no WebAssembly, so installing this does not force 'wasm-unsafe-eval' into your Content Security Policy.

  • shiki loads on first highlight, never on import.
  • Each language is its own chunk, resolved through a static map.
  • highlight={false} and the lines prop never request any of it.

Adding a language

Languages are a written-out map in code-block-highlight.tsx. Add a line:

export const codeBlockLanguages = {
  // ...
  elixir: () => import("shiki/langs/elixir.mjs"),
}

Aliases resolve first, so shorthands live one map up: add ex: "elixir" to LANGUAGE_ALIASES and both names work. An entry does not have to come from shiki either - mylang: () => import("./my-grammar.json") registers a custom TextMate grammar, since the loader feeds loadLanguage directly. Anything unresolved renders as plain text.

The map is written out on purpose. Building the specifier from a template literal instead makes bundlers emit a context module containing every grammar shiki ships, which quietly turns a lean install into a very large one.

Theming

Three levels, from zero effort to full control:

  1. Defaults. Blocks ship with github-light / github-dark and switch with your site theme automatically.
  2. Any shiki theme. Register it in codeBlockThemes in the copied code-block-highlight.tsx (one line, lazy-loaded), then pass themes={{ light: "vitesse-light", dark: "vitesse-dark" }}. Changing the two defaults in that file restyles every block at once. An unregistered name falls back to its side's default and warns in development - check the codeBlockThemes entry first when a block renders in the wrong palette.
  3. Design tokens. Pass the built-in css-variables theme and the palette moves into your stylesheet: every token colour becomes a var(--code-token-*) reference, so highlighting follows your design system in both modes with no theme JSON at all.
<CodeBlock
  code={code}
  language="tsx"
  themes={{ light: "css-variables", dark: "css-variables" }}
/>
:root {
  --code-token-keyword: var(--color-rose-600);
  --code-token-function: var(--color-violet-600);
  --code-token-string: var(--color-emerald-600);
  --code-token-constant: var(--color-sky-600);
  --code-token-parameter: var(--color-amber-600);
  --code-token-comment: var(--color-muted-foreground);
  --code-token-punctuation: var(--color-foreground);
}
.dark {
  --code-token-keyword: var(--color-rose-400);
  /* ...and so on for the dark side of each token */
}

The full variable set: --code-foreground, and --code-token-constant, -string, -comment, -keyword, -parameter, -function, -string-expression, -punctuation, -link.

Blocks with different themes props coexist on one page; each theme loads once, by name, on first use.

The design-token example is the one embedded under Scrolling above, and it also lives on the Code Block components page.

Dark Theme

Code often stays dark even on a light page. Wrap the block in a dark scope and every semantic token inside follows; the primitive needs nothing.

<div className="dark">
  <CodeBlock code={code} language="tsx" />
</div>
import {
  CodeBlock,
  CodeBlockCopyButton,
  CodeBlockHeader,
  CodeBlockTitle,
} from "@/components/reui/code-block/code-block"

const code = `export function Hero({ headline, snippet }: HeroProps) {
  const seats = useSeatCount()

  return (
    <section className="dark bg-background py-24">
      <h1 className="text-4xl font-semibold">{headline}</h1>
      <img src={cover} />
      <pre className="overflow-x-auto">{snippet}</pre>
      <CodeBlock code={snippet} language="tsx" showLineNumbers />
      <a onClick={goToPricing}>Start free</a>
      <p>{seats} seats claimed</p>
    </section>
  )
}`

/*
 * A block that stays dark in both site themes, the way marketing pages and
 * landing heroes usually want code to read. The `dark` class on the wrapper
 * re-scopes every semantic token inside it, so the primitive needs nothing:
 * the highlight, both diff tints, the three diagnostic levels and the word
 * mark all resolve against the dark palette here instead of the page's.
 */
export function Pattern() {
  return (
    <div className="dark w-full max-w-2xl">
      <CodeBlock
        code={code}
        language="tsx"
        showLineNumbers
        highlightedLines={[5]}
        highlightedWords={["useSeatCount"]}
        diff={{ removed: [8], added: [9] }}
        lineLevels={{ error: [10], warning: [7], info: [11] }}
      >
        <CodeBlockHeader>
          <span
            aria-hidden="true"
            className="flex shrink-0 items-center gap-1.5"
          >
            <span className="size-2.5 rounded-full bg-[#ff5f57]" />
            <span className="size-2.5 rounded-full bg-[#febc2e]" />
            <span className="size-2.5 rounded-full bg-[#28c840]" />
          </span>
          <CodeBlockTitle className="ml-1">hero.tsx</CodeBlockTitle>
          <CodeBlockCopyButton className="ml-auto" />
        </CodeBlockHeader>
      </CodeBlock>
    </div>
  )
}

Examples

A representative six. Every example on this page installs the same way, and the rest stay browsable on the Code Block components page.

Pinned copy button

import {
  CodeBlock,
  CodeBlockContent,
  CodeBlockCopyButton,
} from "@/components/reui/code-block/code-block"

import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"

const code = `export async function resolveWorkspaceMembershipForActiveSubscription(workspaceId: string, userId: string) {
  const membership = await db.membership.findFirst({ where: { workspaceId, userId, status: "active" } })
  if (!membership) throw new WorkspaceAccessError("No active membership for this workspace")
  return membership
}

export function formatSeatSummary(seats: number, used: number) {
  return \`\${used} of \${seats} seats in use, \${Math.max(0, seats - used)} remaining\`
}

export function countBillableSeats(members: Member[]) {
  return members.filter((member) => member.status === "active" && !member.isGuest).length
}

export function canInviteMember(seats: number, used: number) {
  return used < seats
}`

/**
 * Both axes belong to the consumer's ScrollArea here: `CodeBlockContent`
 * hands the surface over without an internal scroll container, and the block
 * itself stays the bordered chrome. `max-h` rather than a fixed height, so a
 * short file does not leave dead space under the code.
 *
 * With no header the copy button pins itself over the code surface, hidden
 * until the block is hovered or the button takes focus. Line 1 scrolls
 * underneath it, so the button needs a backdrop of its own: `outline` draws
 * the border, and `bg-card` fills it (the variant's own dark fill is 4.5%
 * opaque, which the code shows through).
 */
export function Pattern() {
  return (
    <CodeBlock code={code} language="typescript" className="w-full max-w-2xl">
      <CodeBlockCopyButton
        variant="outline"
        size="icon-sm"
        className="bg-card hover:bg-muted"
      />
      {/* The cap goes on the ScrollArea VIEWPORT, not its root: the viewport
          is height-100% of the root, and a percentage against a max-height
          only parent resolves to auto, so a root-level cap clips without ever
          scrolling. */}
      <ScrollArea className="rounded-[inherit] **:data-[slot=scroll-area-viewport]:max-h-56">
        <CodeBlockContent />
        <ScrollBar orientation="horizontal" />
      </ScrollArea>
    </CodeBlock>
  )
}

Diff

Line state comes straight from props here - the simplest form. For a real patch, see the unified parse below.

import {
  CodeBlock,
  CodeBlockCopyButton,
  CodeBlockHeader,
  CodeBlockTitle,
} from "@/components/reui/code-block/code-block"

const code = `export function getCacheKey(request: Request) {
  const url = new URL(request.url)
  return url.href
  url.searchParams.delete("utm_source")
  url.searchParams.delete("utm_medium")
  url.searchParams.sort()
  return url.toString()
}`

export function Pattern() {
  return (
    <div className="w-full max-w-2xl">
      <CodeBlock
        code={code}
        language="typescript"
        showLineNumbers
        diff={{ added: [4, 5, 6, 7], removed: [3] }}
      >
        <CodeBlockHeader>
          <CodeBlockTitle>lib/cache-key.ts</CodeBlockTitle>
          <CodeBlockCopyButton className="ml-auto" />
        </CodeBlockHeader>
      </CodeBlock>
    </div>
  )
}

Nested code folding

Regions come from indentation rather than the grammar, so folding works in every language the block can render. Folding an outer region swallows the inner toggles and leaves their own state untouched underneath.

"use client"

import { useState } from "react"
import {
  CodeBlock,
  CodeBlockContent,
  CodeBlockCopyButton,
  CodeBlockHeader,
  CodeBlockTitle,
} from "@/components/reui/code-block/code-block"

import { Button } from "@/components/ui/button"
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"
import { Separator } from "@/components/ui/separator"

const code = `export async function syncWorkspace(workspaceId: string) {
  const workspace = await db.workspace.findUnique({ where: { id: workspaceId } })

  if (!workspace) {
    throw new WorkspaceNotFoundError(workspaceId)
  }

  const members = await db.member.findMany({ where: { workspaceId } })

  for (const member of members) {
    if (member.status === "invited") {
      await mail.send({
        to: member.email,
        template: "workspace-reminder",
      })
    }
  }

  return { synced: members.length }
}`

/*
 * The block detects its own regions from indentation, so the only reason this
 * list exists is the "Fold all" button: the snippet is a literal here, so its
 * region starts are known without asking the primitive for them.
 *
 * The ScrollArea composes INSIDE the block, under the header, so the fold
 * controls never scroll away and the block's own chrome stays the container.
 */
const REGION_STARTS = [1, 4, 10, 11, 12]

export function Pattern() {
  /* Opens with the inner branch folded, so the nesting is visible at a glance:
     folding line 10 swallows it, and unfolding 10 brings it back still
     folded. */
  const [folded, setFolded] = useState<number[]>([11])

  return (
    <CodeBlock
      code={code}
      language="typescript"
      showLineNumbers
      className="w-full max-w-2xl"
      foldable
      folded={folded}
      onFoldedChange={setFolded}
    >
      <CodeBlockHeader className="gap-1.5">
        <CodeBlockTitle>sync-workspace.ts</CodeBlockTitle>
        <div className="ml-auto flex items-center gap-1">
          <Button
            size="xs"
            variant="ghost"
            onClick={() => setFolded(REGION_STARTS)}
          >
            Fold all
          </Button>
          <Button
            size="xs"
            variant="ghost"
            disabled={!folded.length}
            onClick={() => setFolded([])}
          >
            Unfold all
          </Button>
          <Separator orientation="vertical" className="h-4" />
          <CodeBlockCopyButton />
        </div>
      </CodeBlockHeader>
      <ScrollArea className="rounded-[inherit] **:data-[slot=scroll-area-viewport]:max-h-72">
        <CodeBlockContent />
        <ScrollBar orientation="horizontal" />
      </ScrollArea>
    </CodeBlock>
  )
}

Fix with AI on the failing line

The render prop receives each line's state, so one action group serves the whole block and still only offers the fix where a diagnostic actually sits.

"use client"

import { useState } from "react"
import { Badge } from "@/components/reui/badge"
import {
  CodeBlock,
  CodeBlockHeader,
  CodeBlockLineActions,
  CodeBlockTitle,
} from "@/components/reui/code-block/code-block"

import { Button } from "@/components/ui/button"
import { SparklesIcon } from 'lucide-react'

const broken = `export function totalDue(invoice: Invoice) {
  const lines = invoice.lines.map((line) => line.amount)
  const subtotal = lines.reduce((sum, amount) => sum + amount)
  return subtotal + invoice.tax
}`

const patched = `export function totalDue(invoice: Invoice) {
  const lines = invoice.lines.map((line) => line.amount)
  const subtotal = lines.reduce((sum, amount) => sum + amount, 0)
  return subtotal + invoice.tax
}`

export function Pattern() {
  const [fixed, setFixed] = useState(false)

  return (
    <div className="w-full max-w-2xl">
      <CodeBlock
        code={fixed ? patched : broken}
        language="typescript"
        showLineNumbers
        lineLevels={fixed ? undefined : { error: [3] }}
      >
        <CodeBlockHeader>
          <CodeBlockTitle>invoice.ts</CodeBlockTitle>
          <div className="ml-auto flex items-center gap-1.5">
            {fixed ? (
              <>
                <Badge variant="success-light">Patch applied</Badge>
                <Button
                  size="xs"
                  variant="ghost"
                  onClick={() => setFixed(false)}
                >
                  Undo
                </Button>
              </>
            ) : (
              <Badge variant="destructive-light">
                Empty array crashes reduce
              </Badge>
            )}
          </div>
        </CodeBlockHeader>

        {/*
          The action is scoped to the line that actually carries the diagnostic:
          the render prop receives that line's state, so one group serves the
          whole block and still only offers the fix where there is something to
          fix.
        */}
        <CodeBlockLineActions>
          {({ state }) =>
            state?.level === "error" ? (
              <Button size="xs" onClick={() => setFixed(true)}>
                <SparklesIcon />
                Fix with AI
              </Button>
            ) : null
          }
        </CodeBlockLineActions>
      </CodeBlock>
    </div>
  )
}

Reference lines in chat

side="gutter" moves the control into the channel beside the line number, where an editor puts it. Pressing it turns the hovered line, or the current selection via useCodeBlockSelection, into file:line reference badges in the composer below.

Unified patch review

parseUnifiedDiff turns a git patch into per-file lines: tints, +/- glyphs and the dual old/new gutter numbers all come from the parse, and the download button saves each file's patch.

import { Badge } from "@/components/reui/badge"
import {
  CodeBlock,
  CodeBlockDownloadButton,
  CodeBlockHeader,
  CodeBlockTitle,
  parseUnifiedDiff,
} from "@/components/reui/code-block/code-block"

const patch = `diff --git a/lib/seats.ts b/lib/seats.ts
--- a/lib/seats.ts
+++ b/lib/seats.ts
@@ -12,7 +12,8 @@ export async function countBillableSeats(workspaceId: string) {
   const members = await db.member.findMany({ where: { workspaceId } })
-  const used = members.length
+  const used = members.filter((member) => !member.isGuest).length
+  if (used < 0) throw new SeatCountError(workspaceId)
   return { used, total: await seatAllowance(workspaceId) }
 }
diff --git a/app/api/invites/route.ts b/app/api/invites/route.ts
--- a/app/api/invites/route.ts
+++ b/app/api/invites/route.ts
@@ -3,6 +3,7 @@ export async function POST(request: Request) {
   const { email } = await request.json()
+  if (!email) return new Response("email required", { status: 422 })
   const invite = await createInvite(session.workspaceId, email)
   return Response.json(invite, { status: 201 })
 }`

/*
 * The whole review renders from ONE `parseUnifiedDiff` call: tints, +/-
 * glyphs and the dual old/new gutter numbers all come out of the parse, so
 * nothing here counts lines against a hand-concatenated string. The parsed
 * lines go in through the `lines` prop, which also means no highlighter runs.
 */
const files = parseUnifiedDiff(patch)

export function Pattern() {
  return (
    <div className="flex w-full max-w-2xl flex-col gap-3">
      {files.map((file) => (
        <CodeBlock
          key={file.file}
          lines={file.lines}
          showLineNumbers
          label={`Patch for ${file.file}`}
        >
          <CodeBlockHeader>
            <CodeBlockTitle>{file.file}</CodeBlockTitle>
            <div className="ml-auto flex items-center gap-1.5">
              <Badge variant="success-light">+{file.added}</Badge>
              <Badge variant="destructive-light">-{file.removed}</Badge>
              <CodeBlockDownloadButton
                filename={`${file.file.split("/").pop()}.patch`}
              />
            </div>
          </CodeBlockHeader>
        </CodeBlock>
      ))}
    </div>
  )
}

The rest

The other fifteen ship exactly as these do and stay browsable on the Code Block components page: ANSI terminal output, a tool-call payload, live search with a match counter, focus walkthroughs, line selection, soft wrap, an expand control over a long file, pre-highlighted server lines, before-and-after panes, a language switcher, a shiki transformer diff, highlighted words, a dark terminal, patch review with accept and reject actions, and a changed-file list inside one scroll area.

API Reference

CodeBlock

The root. Renders the code surface; children are chrome.

PropTypeDefaultDescription
codestring-The source to render.
languagestring-Grammar to use. Unknown values render as plain text.
linesCodeBlockLine[]-Pre-highlighted lines. Skips the client highlighter entirely.
highlightbooleantruefalse renders plain text and never loads shiki.
themes{ light: string; dark: string }github-light / github-darkTheme pair.
showLineNumbersbooleanfalseRenders the gutter as a CSS counter.
startLinenumber1First displayed line number.
wrapboolean-Soft wrap, controlled.
defaultWrapbooleanfalseSoft wrap, uncontrolled.
onWrapChange(wrap: boolean) => void-Fired when wrap changes.
maxLinesnumber-Collapses taller content.
variant"default" | "ghost""default"ghost removes the border and background; padding stays.
labelstringderivedAccessible name for the scroll region.
highlightedLinesnumber[] | string-Source lines to mark, as [2,3] or "2-4,7".
highlightedWords(string | { word, lines })[]-Words to mark.
focusedLinesnumber[] | string-Lines to keep sharp; the rest dim.
diff{ added?, removed? }-Diff lines.
lineLevels{ error?, warning?, info? }-Diagnostic lines.
transformersShikiTransformer[]-Passed to shiki. Line classes become line state.
streamingbooleanfalseCaret, stick-to-bottom, aria-busy.
expandedboolean-Collapsed state under maxLines, controlled.
defaultExpandedbooleanfalseCollapsed state, uncontrolled.
onExpandedChange(expanded: boolean) => void-Fired when the expand control toggles.
completeAnnouncementstringderivedScreen-reader text when a stream finishes, for localisation.
selectablebooleanfalseEnables line selection.
foldRegionsCodeBlockFoldRegion[]derivedReplaces the indentation heuristic with your own regions.
foldablebooleanfalseDetects fold regions from indentation and renders a toggle per region.
foldednumber[]-Folded regions by start line, controlled.
defaultFoldednumber[][]Folded regions by start line, uncontrolled.
onFoldedChange(folded: number[]) => void-Fired when a region folds or unfolds.
selectedLinesnumber[]-Selected lines, controlled.
defaultSelectedLinesnumber[][]Selected lines, uncontrolled.
onSelectedLinesChange(lines: number[]) => void-Fired on selection change.

Line specs address source lines, 1-based within code, so changing startLine never invalidates them.

CodeBlockContent

The code surface as a standalone part. Compose it inside your own scroll container and the block renders no internal scroller; omit it and the block scrolls itself exactly as before. Takes only className.

CodeBlockCopyButton

PropTypeDefaultDescription
valuestringroot codeText to copy. Notation comments are stripped.
onCopy(value: string) => void-Fired after a successful copy.
timeoutnumber2000How long the copied state lasts. 0 keeps it.
position"auto" | "pinned" | "inline""auto"auto is inline in a header, pinned elsewhere.
onCopyError(error: unknown) => void-Fired when the clipboard write rejects.
labels{ copy?, copied? }EnglishAccessible names, for localisation.
alwaysVisiblebooleanfalseSkips the hover reveal when pinned.

CodeBlockDownloadButton

Saves the block's code as a file - the sibling of the copy button for builder-style output. Same position contract; filename defaults to code.<ext> from the block's language.

PropTypeDefaultDescription
valuestringblock codeText to save, notation stripped.
filenamestringderivedName for the saved file.
onDownload(filename: string) => void-Fired after the save starts.
position"auto" | "pinned" | "inline""auto"Same placement contract as copy.
labelstringEnglishAccessible name, for localisation.

CodeBlockLineActions

PropDefaultTypeDescription
children-({ line, text, state }) => ReactNodeRendered on the active line.
side"end""end" | "gutter"end floats the group over the end of the row; gutter centres it on the row's start edge, half over the numbers, without reserving any space. A fold toggle owns that edge, so a gutter action on a fold-start row falls back to end.

CodeBlockExpandButton

Renders nothing when the content is shorter than maxLines, so it is always safe to compose.

useCodeBlockConfig

useCodeBlockConfig(partName) - the string only names your component in the "must be used within a CodeBlock" error. Returns the block's own config for anything composed inside it: code, language, resolvedLanguage, showLineNumbers, wrap / setWrap, expanded / setExpanded, collapsible, streaming and contentId.

useCodeBlockFolding

Live folding for anything composed inside the block: regions, foldedStarts, toggleFold, foldAll, unfoldAll and foldable. Numbers are source-based like every line spec.

useCodeBlockSelection

Live selection for anything composed inside the block - a line action or a header control. Returns selectedLines (sorted), toggleLine, clearSelection and selectable, so an "add selection to chat" affordance can act on the whole range instead of only the hovered line.

Helpers

ExportFromSignatureDescription
highlightCodecode-block-highlight(code, options) => Promise<CodeBlockLine[]>Isomorphic. Safe in a server component.
markdownFencescode-block(markdown) => CodeBlockMarkdownPart[]Splits prose from fenced code; flags an unclosed fence.
markdownCodePropscode-block(preProps) => { code, language }Reads a react-markdown pre.
parseLineSpeccode-block-highlight(spec) => Set<number>"2-4,7" to line numbers.
stripNotationCommentscode-block-highlight(code) => stringRemoves [!code ...] comments.
ansiToLinescode-block(text, startLine?) => CodeBlockLine[]Terminal SGR colours to renderable lines.
parseUnifiedDiffcode-block(patch) => CodeBlockPatchFile[]A git patch to per-file lines with dual gutters.

Data attributes

State is published as attributes, so styling never needs !important and never needs a :has() selector.

AttributeOnValues
data-variantrootdefault, ghost
data-has-headerrootpresent when a header is composed
data-streamingrootpresent while streaming
data-has-diffrootpresent when any line carries diff state
data-foldablerootpresent when folding is enabled
data-gutter-channelrootpresent when folding reserves the gutter channel
data-code-linelinethe displayed line number
data-highlightedlinepresent when marked
data-difflineadd, remove
data-focused / data-blurredlinefocus mode
data-levellineerror, warning, info
data-selectedlinepresent when selected
data-activelinepresent under the keyboard/hover cursor
data-gutterlinea verbatim gutter label (patch old/new pairs)
data-code-line-numbersprepresent when the gutter renders
data-wrapprepresent while soft-wrapping
data-selectableprepresent when line selection is on
data-wordtokenthe matched word on a marked token
data-positioncopy / downloadpinned, inline
data-copiedcopy buttonpresent during the copied beat
data-copy-failedcopy buttonpresent for 2s after a rejected write
data-statefold togglefolded, unfolded
data-statewrap toggleon, off
data-stateexpandexpanded, collapsed
data-sideline actionsend, gutter

Keyboard

KeyAction
TabMoves into the scroll region, which is a tab stop.
↑ ↓Moves the active line when selectable.
Shift + ↑ ↓Extends the selection.
Enter SpaceToggles the active line's selection in place.
Home EndMoves the active line to the first or last rendered line.
EscClears the active line, when selectable.
Enter SpaceFolds or unfolds the region when a fold toggle has focus.

Accessibility

  • With the built-in surface, the scroll container is a labelled region and a tab stop, so it can be scrolled without a pointer. Composed, the keyboard scroll stop is your own container, which then needs its own accessible name.
  • With selectable, the code is a listbox and each line an option carrying aria-selected. The region wrapper holds the only tabIndex={0}, so the block is a single tab stop; with selectable, arrow keys move the active option and aria-activedescendant on that wrapper exposes it to assistive tech.
  • Line numbers and diff glyphs are generated content, so they are outside the accessibility tree and outside text selection: copying gives exact source.
  • Streaming sets aria-busy and announces completion once through a polite status. The code is deliberately not a live region, which would otherwise narrate every token.
import {
  CodeBlock,
  CodeBlockCopyButton,
  CodeBlockHeader,
  CodeBlockLanguage,
  CodeBlockTitle,
} from "@/components/reui/code-block/code-block"

const code = `export function useTotals(items: Item[]) {
  return useMemo(() => {
    const subtotal = items.reduce((sum, item) => sum + item.price, 0)
    const tax = Math.round(subtotal * 0.2)
    return { subtotal, tax, total: subtotal + tax }
  }, [items])
}`

export function Pattern() {
  return (
    <div className="w-full max-w-2xl">
      <CodeBlock code={code} language="typescript" showLineNumbers>
        <CodeBlockHeader>
          <CodeBlockTitle>use-totals.ts</CodeBlockTitle>
          <CodeBlockLanguage />
          <CodeBlockCopyButton className="ml-auto" />
        </CodeBlockHeader>
      </CodeBlock>
    </div>
  )
}
"use client"

import { useState } from "react"
import { Badge } from "@/components/reui/badge"
import {
  CodeBlock,
  CodeBlockHeader,
  CodeBlockLineActions,
  CodeBlockTitle,
  useCodeBlockSelection,
} from "@/components/reui/code-block/code-block"

import { Button } from "@/components/ui/button"
import { Card } from "@/components/ui/card"

const FILE = "use-mobile.ts"
const PATH = "src/hooks/use-mobile.ts"

const code = `import { useEffect, useState } from "react"
import { PlusIcon, XIcon } from 'lucide-react'

const MOBILE_BREAKPOINT = 768

export function useIsMobile() {
  const [isMobile, setIsMobile] = useState<boolean>()

  useEffect(() => {
    const mql = window.matchMedia("(max-width: 767px)")
    const onChange = () => {
      setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
    }
    mql.addEventListener("change", onChange)
    setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
    return () => mql.removeEventListener("change", onChange)
  }, [])

  return !!isMobile
}`

/*
 * The gutter button turns code into REFERENCES: press + on a line and the
 * composer below gains a file:line badge, or select a range first and one
 * press references every selected line. `useCodeBlockSelection` is what makes
 * the range case possible - the render prop alone only knows its own line.
 */
function AddToChat({
  line,
  onAdd,
}: {
  line: number
  onAdd: (lines: number[]) => void
}) {
  const { selectedLines, clearSelection } = useCodeBlockSelection()
  const batch = selectedLines.length > 0 ? selectedLines : [line]

  return (
    <Button
      type="button"
      size="icon-xs"
      aria-label={
        batch.length > 1
          ? `Reference ${batch.length} selected lines in the chat`
          : `Reference line ${line} in the chat`
      }
      title={
        batch.length > 1
          ? `Reference ${batch.length} lines`
          : `Reference line ${line}`
      }
      onClick={() => {
        onAdd(batch)
        clearSelection()
      }}
      /* Trimmed below the icon-xs rung: the control hangs over the line
         numbers, so a full 24px chip covers the digits it floats above. */
      className="size-5"
    >
      <PlusIcon />
    </Button>
  )
}

export function Pattern() {
  const [refs, setRefs] = useState<number[]>([])
  const [highlighted, setHighlighted] = useState<number | null>(null)

  const addLines = (lines: number[]) =>
    setRefs((current) =>
      [...new Set([...current, ...lines])].sort((a, b) => a - b)
    )

  return (
    <div className="flex w-full max-w-2xl flex-col gap-3">
      <CodeBlock
        code={code}
        language="typescript"
        showLineNumbers
        selectable
        highlightedLines={highlighted === null ? undefined : [highlighted]}
      >
        <CodeBlockHeader>
          <CodeBlockTitle>{FILE}</CodeBlockTitle>
          <span className="text-muted-foreground ml-auto text-xs">
            Press + on a line to reference it
          </span>
        </CodeBlockHeader>

        <CodeBlockLineActions side="gutter">
          {({ line }) => <AddToChat line={line} onAdd={addLines} />}
        </CodeBlockLineActions>
      </CodeBlock>

      {/* The composer the references land in. A Card, not a hand-rolled
          bordered div, so its radius resolves per style. */}
      <Card size="sm" className="gap-2 p-3">
        <div className="flex flex-wrap items-center gap-1.5">
          {refs.length === 0 ? (
            <span className="text-muted-foreground px-1 text-sm">
              Ask about {FILE}...
            </span>
          ) : (
            refs.map((line) => (
              <Badge
                key={line}
                variant={highlighted === line ? "primary-light" : "outline"}
                title={`${PATH}:${line}`}
                className="gap-0.5 font-mono"
              >
                {/* Two actions per chip, so two real buttons: the label
                    highlights the referenced line in the block above through
                    `highlightedLines`, the X removes the reference. */}
                <Button
                  variant="ghost"
                  size="xs"
                  aria-pressed={highlighted === line}
                  aria-label={`Highlight line ${line} in the code`}
                  onClick={() =>
                    setHighlighted((current) =>
                      current === line ? null : line
                    )
                  }
                  className="h-auto p-0 font-mono hover:bg-transparent"
                >
                  {FILE}:{line}
                </Button>
                <Button
                  variant="ghost"
                  size="icon-xs"
                  aria-label={`Remove the reference to line ${line}`}
                  onClick={() => {
                    setRefs((current) =>
                      current.filter((value) => value !== line)
                    )
                    setHighlighted((current) =>
                      current === line ? null : current
                    )
                  }}
                  className="size-4 hover:bg-transparent [&_svg]:size-2.5"
                >
                  <XIcon />
                </Button>
              </Badge>
            ))
          )}
        </div>
        <div className="flex items-center gap-2">
          <span className="text-muted-foreground text-xs">
            {refs.length > 0
              ? `${refs.length} ${refs.length === 1 ? "line" : "lines"} in context`
              : "No context yet"}
          </span>
          <Button size="xs" className="ml-auto" disabled={!refs.length}>
            Ask AI
          </Button>
        </div>
      </Card>
    </div>
  )
}