Code Tabs
A code block with file tabs, line numbers, highlighted lines and a copy button; long lines scroll, not wrap.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/code-tabs.json'use client'
import { useEffect, useRef, useState, type ReactNode } from 'react'
import './code-tabs.css'
export interface CodeFile { name: string; code: string; highlight?: number[] }
export interface CodeTabsProps {
files: CodeFile[]
showLineNumbers?: boolean
fill?: 'dark' | 'light'
className?: string
}
const KW = /\b(import|from|export|default|function|return|const|let|await|async|if|else|new|type|interface)\b/
function tokens(line: string): ReactNode[] {
const out: ReactNode[] = []
const re = /(\/\/.*$)|('[^']*'|"[^"]*"|`[^`]*`)|(\b[A-Za-z_]+\b)|([^A-Za-z_'"`/]+|\/|[\s\S])/g
let m: RegExpExecArray | null, k = 0
while ((m = re.exec(line))) {
if (m[1]) out.push(<span key={k++} className="sd-cdb__cm">{m[1]}</span>)
else if (m[2]) out.push(<span key={k++} className="sd-cdb__str">{m[2]}</span>)
else if (m[3] && KW.test(m[3])) out.push(<span key={k++} className="sd-cdb__kw">{m[3]}</span>)
else out.push(m[0])
}
return out
}
/** File-tabbed code block with line highlights and copy. */
export function CodeTabs({ files, showLineNumbers = true, fill = 'dark', className }: CodeTabsProps) {
const [i, setI] = useState(0)
const [copied, setCopied] = useState(false)
const tabs = useRef<(HTMLButtonElement | null)[]>([])
useEffect(() => { if (!copied) return; const t = setTimeout(() => setCopied(false), 1400); return () => clearTimeout(t) }, [copied])
const f = files[i]
const go = (n: number) => { const j = (n + files.length) % files.length; setI(j); tabs.current[j]?.focus() }
return (
<div className={['sd-cdb', className].filter(Boolean).join(' ')} data-fill={fill} data-nolines={!showLineNumbers || undefined}>
<div className="sd-cdb__bar">
<div className="sd-cdb__tabs" role="tablist" aria-label="Files">
{files.map((fl, j) => (
<button key={fl.name} ref={(el) => { tabs.current[j] = el }} type="button" role="tab" aria-selected={i === j} tabIndex={i === j ? 0 : -1} className="sd-cdb__tab" onClick={() => setI(j)} onKeyDown={(e) => { if (e.key === 'ArrowRight') go(j + 1); if (e.key === 'ArrowLeft') go(j - 1) }}>{fl.name}</button>
))}
</div>
<button type="button" className="sd-cdb__copy" aria-label={copied ? 'Copied' : `Copy ${f.name}`} onClick={async () => { try { await navigator.clipboard.writeText(f.code) } catch {} setCopied(true) }}>
{copied ? <svg viewBox="0 0 14 14" aria-hidden><path d="m3 7.5 2.5 2.5L11 4" /></svg> : <svg viewBox="0 0 14 14" aria-hidden><rect x="4.5" y="4.5" width="7" height="7" rx="1.5" /><path d="M9.5 4.5V3a1 1 0 0 0-1-1H3a1 1 0 0 0-1 1v5.5a1 1 0 0 0 1 1h1.5" /></svg>}
</button>
</div>
<pre key={i} className="sd-cdb__code" role="tabpanel" tabIndex={0} aria-label={f.name}>
{f.code.split('\n').map((ln, n) => (
<div key={n} className="sd-cdb__ln" data-hl={f.highlight?.includes(n + 1) || undefined}><span className="sd-cdb__no" aria-hidden>{n + 1}</span><code>{tokens(ln)}</code></div>
))}
</pre>
<span className="sr" aria-live="polite">{copied ? 'Copied to clipboard' : ''}</span>
</div>
)
}.sd-cdb { --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-cdb[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-cdb button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-cdb :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-cdb .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-cdb { width: min(100%, 360px); border-radius: 16px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); overflow: hidden; }
.sd-cdb__bar { display: flex; align-items: center; border-bottom: 1px solid var(--line); padding: 0 6px 0 4px; }
.sd-cdb__tabs { display: flex; flex: 1; min-width: 0; overflow-x: auto; scrollbar-width: none; }
.sd-cdb__tab { position: relative; height: 40px; padding: 0 10px; font: 12px/1 ui-monospace, 'JetBrains Mono', monospace; color: var(--mute); white-space: nowrap; }
.sd-cdb__tab[aria-selected='true'] { color: var(--fg); }
.sd-cdb__tab[aria-selected='true']::after { content: ''; position: absolute; left: 10px; right: 10px; bottom: -1px; height: 1.5px; background: var(--fg); }
.sd-cdb__copy { display: grid; place-items: center; width: 30px; height: 30px; border-radius: 8px; color: var(--mute); flex: none; }
.sd-cdb__copy:hover { color: var(--fg); background: var(--soft) !important; }
.sd-cdb__copy svg { width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; }
.sd-cdb__code { margin: 0; padding: 10px 0; overflow-x: auto; font: 12.5px/1.65 ui-monospace, 'JetBrains Mono', monospace; outline: 0; animation: sd-cdb-in 220ms ease; }
.sd-cdb__code:focus-visible { box-shadow: inset 0 0 0 2px var(--fg); }
@keyframes sd-cdb-in { from { opacity: 0; } }
.sd-cdb__ln { display: flex; min-width: max-content; padding-right: 14px; }
.sd-cdb__ln[data-hl] { background: var(--soft); box-shadow: inset 2px 0 0 var(--fg); }
.sd-cdb__no { flex: none; width: 36px; padding-right: 12px; text-align: right; color: var(--mute); opacity: 0.6; user-select: none; }
.sd-cdb[data-nolines] .sd-cdb__no { width: 14px; visibility: hidden; }
.sd-cdb__kw { font-weight: 700; }
.sd-cdb__str { color: var(--mute); }
.sd-cdb__cm { color: var(--mute); font-style: italic; }
@media (prefers-reduced-motion: reduce) { .sd-cdb *, .sd-cdb *::before, .sd-cdb *::after { animation: none !important; transition: none !important; } }Overview
Tabs switch between files (or languages). Lines you mark are highlighted with a bar in the gutter, and line numbers are selectable-proof so copying gets only code.
Copy confirms in place. Syntax colouring is deliberately left out; tokens get weight, not colour, to stay monochrome.
When to use
- Docs, READMEs on the web, API references, blog posts.
When not to use
- Editable code: use an editor component.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/code-tabs.json$
npx shadcn@latest add https://sundaeui.dev/r/code-tabs.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/code-tabs.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/code-tabs.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 code-tabs.tsx and code-tabs.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 { CodeTabs } from '@/components/sundae/code-tabs'
<CodeTabs />Props
| Prop | Type | Default |
|---|---|---|
files*Files. | { name: string; code: string; highlight?: number[] }[] | — |
showLineNumbersGutter numbers. | boolean | true |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- WAI-ARIA tabs; the code is a focusable region so it can scroll by keyboard.
- Copy announces the result.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-cdb; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
Keyboard
- ← / →
- Switch file
FAQ
- Is Code Tabs free?
- Yes. Code Tabs is free for personal and commercial projects, with full source on this page.
- How do I install Code Tabs?
- Run npx shadcn@latest add https://sundaeui.dev/r/code-tabs.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Code Tabs support keyboard and screen readers?
- WAI-ARIA tabs; the code is a focusable region so it can scroll by keyboard. Copy announces the result.
- Does Code Tabs respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All components →Copy Command
Components · UnderstatedAPI Key Row
Components · RefinedShare Sheet
Components · RefinedContact Card
Components · RefinedMini Terminal
Components · Refined- Pro
Glass Segmented
Components · Refined