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>
)
}








