Query Runner
A compact SQL scratchpad: saved snippets, run with a keystroke, a result grid with row count and timing, and CSV copy.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/query-runner.json'use client'
import { useState } from 'react'
import './query-runner.css'
type Cell = string | number | null
export interface QueryResult { columns: string[]; rows: Cell[][] }
export interface QueryRunnerProps {
run: (sql: string) => Promise<QueryResult>
snippets?: { name: string; sql: string }[]
fill?: 'dark' | 'light'
className?: string
}
/** SQL scratchpad with results grid. */
export function QueryRunner({ run, snippets = [], fill = 'dark', className }: QueryRunnerProps) {
const [sql, setSql] = useState(snippets[0]?.sql ?? '')
const [busy, setBusy] = useState(false)
const [res, setRes] = useState<(QueryResult & { ms: number }) | null>(null)
const [err, setErr] = useState<string | null>(null)
const [copied, setCopied] = useState(false)
const go = async () => {
if (!sql.trim()) return
setBusy(true); setErr(null); const t0 = performance.now()
try { const r = await run(sql); setRes({ ...r, ms: Math.round(performance.now() - t0) }) } catch (e) { setRes(null); setErr(e instanceof Error ? e.message : String(e)) }
setBusy(false)
}
const csv = () => res ? [res.columns, ...res.rows].map((r) => r.map((c) => (c === null ? '' : /[",\n]/.test(String(c)) ? `"${String(c).replace(/"/g, '""')}"` : String(c))).join(',')).join('\n') : ''
return (
<div className={['sd-qry', className].filter(Boolean).join(' ')} data-fill={fill}>
{snippets.length > 0 && <div className="sd-qry__sn" role="toolbar" aria-label="Saved queries">{snippets.map((s) => <button key={s.name} type="button" aria-pressed={sql === s.sql} onClick={() => setSql(s.sql)}>{s.name}</button>)}</div>}
<textarea aria-label="SQL" value={sql} spellCheck={false} onChange={(e) => setSql(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); go() } }} />
<div className="sd-qry__run">
<span aria-live="polite">{busy ? 'Running…' : err ? 'Query failed' : res ? `${res.rows.length} row${res.rows.length === 1 ? '' : 's'} · ${res.ms} ms` : '⌘↵ to run'}</span>
<button type="button" disabled={!res?.rows.length} onClick={() => { navigator.clipboard?.writeText(csv()).catch(() => {}); setCopied(true); setTimeout(() => setCopied(false), 1200) }}>{copied ? 'Copied' : 'Copy CSV'}</button>
<button type="button" data-pri disabled={busy || !sql.trim()} onClick={go}>Run</button>
</div>
<div className="sd-qry__out">
{err ? <div className="sd-qry__err" role="alert">{err}</div> : res ? (
res.rows.length ? (
<table><thead><tr>{res.columns.map((c) => <th key={c} scope="col">{c}</th>)}</tr></thead>
<tbody>{res.rows.map((r, i) => <tr key={i}>{r.map((c, j) => <td key={j} data-null={c === null || undefined} data-num={typeof c === 'number' || undefined}>{c === null ? 'null' : String(c)}</td>)}</tr>)}</tbody></table>
) : <div className="sd-qry__msg">No rows</div>
) : <div className="sd-qry__msg">Results appear here</div>}
</div>
</div>
)
}.sd-qry { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-qry[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-qry :where(button) { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-qry :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-qry .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-qry { width: min(100%, 360px); grid-template-columns: minmax(0, 1fr); border-radius: 18px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); display: grid; overflow: hidden; }
.sd-qry__sn { display: flex; gap: 4px; padding: 8px; overflow-x: auto; scrollbar-width: none; border-bottom: 1px solid var(--line); }
.sd-qry__sn button { flex: none; padding: 5px 10px; border-radius: 8px; font-size: 12px; color: var(--mute) !important; }
.sd-qry__sn button[aria-pressed='true'] { background: var(--soft) !important; color: var(--fg) !important; }
.sd-qry textarea { display: block; width: 100%; height: 84px; padding: 10px 12px; border: 0; background: var(--bg); color: var(--fg); font: 12.5px/1.55 ui-monospace, monospace; resize: none; outline: 0; box-sizing: border-box; }
.sd-qry__run { display: flex; align-items: center; gap: 8px; padding: 6px 10px; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); font-size: 11.5px; color: var(--mute); }
.sd-qry__run span { flex: 1; }
.sd-qry__run button { height: 28px; padding: 0 10px; border-radius: 7px; font-size: 12px; font-weight: 600; box-shadow: inset 0 0 0 1px var(--line); color: var(--fg) !important; }
.sd-qry__run button[data-pri] { background: var(--strong) !important; color: var(--on) !important; box-shadow: none; }
.sd-qry__run button:disabled { opacity: 0.4; }
.sd-qry__out { max-height: 190px; overflow: auto; }
.sd-qry table { border-collapse: collapse; width: 100%; font: 11.5px ui-monospace, monospace; }
.sd-qry th { position: sticky; top: 0; background: var(--bg); text-align: left; font-weight: 600; padding: 6px 10px; border-bottom: 1px solid var(--line); white-space: nowrap; }
.sd-qry td { padding: 5px 10px; border-bottom: 1px solid var(--line); white-space: nowrap; font-variant-numeric: tabular-nums; }
.sd-qry td[data-null] { color: var(--mute); font-style: italic; }
.sd-qry td[data-num] { text-align: right; }
.sd-qry tbody tr:hover { background: var(--soft); }
.sd-qry__msg { padding: 30px 12px; text-align: center; font-size: 12.5px; color: var(--mute); }
.sd-qry__err { margin: 10px; padding: 8px 10px; border-radius: 8px; box-shadow: inset 0 0 0 1.5px var(--fg); font: 11.5px/1.5 ui-monospace, monospace; }
@media (prefers-reduced-motion: reduce) { .sd-qry *, .sd-qry *::before, .sd-qry *::after { animation: none !important; transition: none !important; } }Overview
Snippets along the top load a query into the editor. Run executes it through your handler and shows the rows in a small scrolling table.
Errors come back inline with the message in place of the grid. Copy CSV puts the result on the clipboard.
When to use
- Admin panels, data tooling, internal dashboards.
When not to use
- Production write access: guard it with roles.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/query-runner.json$
npx shadcn@latest add https://sundaeui.dev/r/query-runner.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/query-runner.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/query-runner.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy query-runner.tsx and query-runner.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { QueryRunner } from '@/components/sundae/query-runner'
<QueryRunner />Props
| Prop | Type | Default |
|---|---|---|
run*Executes SQL. | (sql: string) => Promise<{ columns: string[]; rows: (string | number | null)[][] }> | — |
snippetsSaved queries. | { name: string; sql: string }[] | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- Results are a real table with column headers.
- Run state and row counts are announced.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-qry; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
Keyboard
- ⌘ / Ctrl + Enter
- Run
FAQ
- Is Query Runner free?
- Yes. Query Runner is free for personal and commercial projects, with full source on this page.
- How do I install Query Runner?
- Run npx shadcn@latest add https://sundaeui.dev/r/query-runner.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Query Runner support keyboard and screen readers?
- Results are a real table with column headers. Run state and row counts are announced.
- Does Query Runner respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All data →Log Stream
Data · RefinedData Table
Data · UnderstatedStacked Bars
Data · Refined- Pro
Scrub Sparkline
Data · Refined - Pro
Tick Gauge
Data · Refined Countdown Timer
Data · Refined