Aadi

SQL Result

Component

About

SQL an agent runs, shown in the thread: the query with syntax colors and a fold for long ones, running with a timer, then rows with sortable columns, row count and duration, copy and CSV export.

Built with React and Tailwind. Copy the prompt and paste it into Claude Code, Cursor or Lovable: your agent adds SQL Result to your project and fits it to your theme.

Code

How SQL Result is used: the demo's code. The component's own source comes with a free account.

Usage.tsx

"use client"

import * as React from "react"
import { SqlColumn, SqlResult, SqlRow } from "@/components/ui/sql-result"

const reducedMotion = () => window.matchMedia("(prefers-reduced-motion: reduce)").matches

interface Query {
  query: string
  columns: SqlColumn[]
  rows: SqlRow[]
  durationMs: number
}

const churnQuery = `SELECT a.name AS account, s.plan, a.region, s.canceled_at::date AS canceled_on,
       s.mrr_cents / 100.0 AS mrr_lost, e.payload ->> 'reason' AS cancel_reason
FROM subscriptions s
JOIN accounts a ON a.id = s.account_id
LEFT JOIN events e ON e.subscription_id = s.id AND e.type = 'cancel_survey'
WHERE s.canceled_at >= '2026-03-01' AND s.canceled_at < '2026-04-01'
ORDER BY mrr_lost DESC;`

/** Fixtures for the sql-result examples: an analytics agent looking into March 2026 churn. */

const money2 = (value: unknown) =>
  Number(value).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })

const churnColumns: SqlColumn[] = [
  { name: "account" },
  { name: "plan" },
  { name: "region" },
  { name: "canceled_on" },
  { name: "mrr_lost", format: money2 },
  { name: "cancel_reason" },
]

const churnRows: SqlRow[] = [
  [
    "Ostrava Dental Group",
    "Business",
    "EU",
    "2026-03-28",
    2400,
    "Moved scheduling into our practice management system, which now ships its own calendar. Paying per seat for 40 front desk staff no longer made sense.",
  ],
  [
    "Halden Freight",
    "Business",
    "EU",
    "2026-03-14",
    1840,
    "Dispatch needs recurring slots that follow shift rotations. We asked twice and it is still not on the roadmap.",
  ],
  ["Pinecrest Veterinary", "Team", "NA", "2026-03-21", 1260, null],
  ["Marlow & Finch Architects", "Team", "EU", "2026-03-03", 980, "Price increase on renewal."],
  [
    "Tidewater Physical Therapy",
    "Team",
    "NA",
    "2026-03-19",
    760,
    "Clients kept missing the reminder texts after the SMS provider change in February. Two no-shows a day is more than the plan costs.",
  ],
  ["Kumo Learning Studio", "Starter", "APAC", "2026-03-09", 588, "Closed the second location."],
  ["Brightside Tutoring", "Starter", null, "2026-03-25", 450, null],
  [
    "Vela Coworking",
    "Starter",
    "APAC",
    "2026-03-11",
    396,
    "Switched to the booking tool bundled with our access control system.",
  ],
  ["Copper Kettle Catering", "Starter", "NA", "2026-03-30", 290, "Too expensive for two users."],
  ["Eastgate Hearing Clinic", "Starter", "NA", "2026-03-17", 147, null],
]

const money = (value: unknown) =>
  Number(value).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })

/** An analytics agent digging into churn: the detail, then two follow up questions. */
const QUERIES: Query[] = [
  {
    query: churnQuery,
    columns: churnColumns,
    rows: churnRows.map((r) => { const a = r as unknown[]; return [a[0], a[1], a[2], a[4]] as SqlRow }),
    durationMs: 184,
  },
  {
    query: `SELECT s.plan, count(*) AS canceled, sum(s.mrr_cents) / 100.0 AS mrr_lost
FROM subscriptions s
WHERE s.canceled_at >= '2026-03-01' AND s.canceled_at < '2026-04-01'
GROUP BY s.plan
ORDER BY mrr_lost DESC;`,
    columns: [{ name: "plan" }, { name: "canceled" }, { name: "mrr_lost", format: money }],
    rows: [
      ["Business", 2, 4240],
      ["Team", 3, 3000],
      ["Starter", 4, 1722],
    ],
    durationMs: 61,
  },
  {
    query: `SELECT a.region, count(*) AS canceled, round(avg(s.seats)) AS avg_seats
FROM subscriptions s JOIN accounts a ON a.id = s.account_id
WHERE s.canceled_at >= '2026-03-01' AND s.canceled_at < '2026-04-01'
GROUP BY a.region
ORDER BY canceled DESC;`,
    columns: [{ name: "region" }, { name: "canceled" }, { name: "avg_seats" }],
    rows: [
      ["EU", 4, 31],
      ["NA", 2, 18],
      ["APAC", 2, 9],
      [null, 1, 6],
    ],
    durationMs: 77,
  },
]

const RUN_MS = 700

const SHOW_MS = 2000

const FIT_CSS = "html,body{overflow:hidden}[data-fit-small] [data-fit-hide]{display:none!important}"

/**
 * 21st shows a demo in a small frame: about 674 by 421 on a component page, 358 by 224 on a
 * phone and 245 by 139 on a profile card. A demo taller than its frame gets scrollbars, which
 * render white on dark pages. FitFrame fills the frame exactly instead.
 *
 * - "scale": the content shrinks until it fits, centered. For visuals such as orbs.
 * - "crop": the content stays at full size from the top and fades out at the bottom. For text
 *   that streams in, where a changing scale would make the page breathe.
 *
 * `minWidth` lays out a wide demo (a table, a two column panel) at that width and scales it down
 * to the frame, instead of letting it collapse to a narrow layout. The frame centers it, so it
 * shrinks evenly on both sides.
 *
 * Anything marked `data-fit-hide` (state buttons, the chatcn.dev line) drops out in frames
 * shorter than `hideBelow`, so a profile card shows the visual alone.
 */
function FitFrame({
  children,
  mode = "scale",
  hideBelow = 360,
  minWidth,
}: {
  children: React.ReactNode
  mode?: "scale" | "crop"
  hideBelow?: number
  minWidth?: string
}) {
  const content = React.useRef<HTMLDivElement>(null)
  const [cropped, setCropped] = React.useState(false)

  React.useLayoutEffect(() => {
    const el = content.current
    if (!el) return
    const measure = () => {
      const vw = window.innerWidth
      const vh = window.innerHeight
      el.toggleAttribute("data-fit-small", vh < hideBelow)
      // Layout sizes ignore the transform, so measuring never feeds back.
      const w = Math.max(el.offsetWidth, el.scrollWidth)
      const h = Math.max(el.offsetHeight, el.scrollHeight)
      const scale = mode === "scale" ? Math.min(1, vw / w, vh / h) : 1
      el.style.transform = scale < 1 ? `scale(${scale})` : ""
      setCropped(mode === "crop" && h > vh + 1)
    }
    measure()
    const observer = new ResizeObserver(measure)
    observer.observe(el)
    // Content that changes inside a fixed width box (a headline that cycles words) never resizes
    // the box itself, so changes below it measure again on the next frame.
    let frame = 0
    const mutations = new MutationObserver(() => {
      cancelAnimationFrame(frame)
      frame = requestAnimationFrame(measure)
    })
    mutations.observe(el, { subtree: true, childList: true, characterData: true })
    window.addEventListener("resize", measure)
    return () => {
      observer.disconnect()
      mutations.disconnect()
      cancelAnimationFrame(frame)
      window.removeEventListener("resize", measure)
    }
  }, [mode, hideBelow])

  const fade = "linear-gradient(to bottom, #000 calc(100% - 40px), transparent)"
  return (
    <div
      className="flex w-full justify-center overflow-hidden"
      style={{
        height: "100dvh",
        alignItems: mode === "crop" ? "flex-start" : "center",
        maskImage: cropped ? fade : undefined,
        WebkitMaskImage: cropped ? fade : undefined,
      }}
    >
      <style>{FIT_CSS}</style>
      <div
        ref={content}
        className="w-full shrink-0"
        style={{ transformOrigin: mode === "crop" ? "top center" : "center", minWidth }}
      >
        {children}
      </div>
    </div>
  )
}

/** The line under each 21st demo that points back to the kit. */
function MoreOn({ slug, children }: { slug: string; children: React.ReactNode }) {
  return (
    <p data-fit-hide className="text-[0.75rem]/[1rem] text-(color:--chatcn-fg-faint) text-center text-balance">
      {children}{" "}
      <a
        href={`https://chatcn.dev/docs/components/${slug}`}
        target="_blank"
        rel="noopener"
        className="text-(color:--chatcn-fg-subtle) hover:text-(color:--chatcn-fg) underline underline-offset-2"
      >
        chatcn.dev
      </a>
    </p>
  )
}

/**
 * An analytics agent answers a churn question with SQL: the lost accounts, then churn by plan,
 * then by region. Each query shows running with its timer, then lands with its rows, row count
 * and duration. Loops. Sort a column, copy the query or export the rows to CSV.
 */
export default function SqlResultPreview() {
  const [step, setStep] = React.useState({ query: 0, running: false })
  const [startedAt, setStartedAt] = React.useState<number>()

  React.useEffect(() => {
    if (reducedMotion()) return
    const id = window.setTimeout(
      () => {
        setStep((s) => {
          if (s.running) return { query: s.query, running: false }
          setStartedAt(Date.now())
          return { query: (s.query + 1) % QUERIES.length, running: true }
        })
      },
      step.running ? RUN_MS : SHOW_MS,
    )
    return () => window.clearTimeout(id)
  }, [step])

  const q = QUERIES[step.query]!
  const columns = step.query === 0 ? [churnColumns[0]!, churnColumns[1]!, churnColumns[2]!, churnColumns[4]!] : q.columns
  return (
    <FitFrame minWidth="44rem">
      <div className="flex w-full flex-col items-center justify-center gap-4 p-6">
        <div className="h-[25.5rem] w-full max-w-2xl">
          <SqlResult
            className="w-full"
            database="analytics"
            query={q.query}
            state={step.running ? "running" : "done"}
            startedAt={step.running ? startedAt : undefined}
            columns={columns}
            rows={step.running ? [] : q.rows}
            durationMs={step.running ? undefined : q.durationMs}
            maxHeight={168}
            collapseAfter={3}
            exportFilename="march-2026-churn.csv"
          />
        </div>
        <MoreOn slug="sql-result">Queries an agent runs, with rows, timing, sort and export. On</MoreOn>
      </div>
    </FitFrame>
  )
}

Dependencies

More by Aadi

View all
  • Dither Orb

    Dither Orb

    12
  • Audio Visualizer

    Audio Visualizer

    9
  • Voice Orb

    Voice Orb

    8
  • Agent Lanes

    Agent Lanes

    6
  • Computer View

    Computer View

    3
  • Waveform

    Waveform

    2
  • Reasoning

    Reasoning

    2
  • Voice Session

    Voice Session

    1

Explore more

Part of chatcn — browse the full library on 21st.dev.