A browser window around a screenshot or any content: the three lights, an address bar with a lock, optional tabs with one active.
Components / UI
Code Tabs
A code block with tabs (for example npm, pnpm, bun) and a copy button per panel. Highlighted at build by Astro's own <Code> (Shiki) with light and dark themes as CSS variables, so it follows the page theme with no script. The tabs are radio inputs and :has(), so arrow keys and selection are native and the indicator slides on the preset. The only script is CopyButton's, shared; long lines scroll inside the panel.
Live demo
npx astro add moonarcpnpm astro add moonarcbunx astro add moonarcLive, from the library itself: scroll, hover, navigate away and back. The demo is never gated.
Adjust · props are custom properties, set inline, so you edit the running instance
Measured
JavaScript of its own
0 B
Uses CopyButton and the shared runtime (1.9 kB raw, once per site). With those included: 2.5 kB raw.
CSS 14.3 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.
Browser support
Chrome 117 · Firefox 129 · Safari 17.5; needs has, starting-style, clipboard. Elsewhere: panels switch without the fade.
Install
One command adds the integration, the base tokens and every component. Then import what you use.
npx astro add moonarcpnpm astro add moonarcbunx astro add moonarc---
import CodeTabs from '@moonarc/core/CodeTabs';
---
<CodeTabs
id="install"
tabs={[
{ label: 'npm', code: 'npx astro add moonarc' },
{ label: 'pnpm', code: 'pnpm astro add moonarc' },
{ label: 'bun', code: 'bunx astro add moonarc' },
]}
/>Copy it into your project instead (shadcn registry)
Owns the file, no dependency. The registry item also installs readableCode(), Copy Button, the shared runtime, Animated Icons and the base tokens.
npx shadcn@latest add https://moonarc.dev/r/code-tabs.jsonThe CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.
Source
The whole component. Self-contained styles in a cascade layer so your classes always win. If you paste it, also copy contrast.ts, CopyButton.astro, runtime.ts and AnimatedIcons.astro.
---
/**
* CodeTabs — a code block with tabs (npm / pnpm / bun …) and a copy button
* per panel. Highlighted at build by Astro's own <Code> (Shiki) with a
* light and a dark theme as CSS variables, so the block follows the page's
* theme with no script; every token colour is moved at build until it reads at
* 4.5 : 1 on the panel (lib/contrast), whichever themes are passed. The tabs are the Tabs pattern — radio inputs and
* :has() — so arrow keys, focus and one-selected are native and the
* indicator slides on the preset. The only script is CopyButton's, shared.
* Long lines scroll inside the panel; the page never widens.
*/
import type { HTMLAttributes } from 'astro/types';
import { Code } from 'astro:components';
import CopyButton from './CopyButton.astro';
import { readableCode } from '../lib/contrast';
interface Tab {
label: string;
code: string;
/** Shiki language id. */
lang?: string;
}
interface Props extends HTMLAttributes<'div'> {
tabs: Tab[];
/** Radio group name and id prefix; unique per page. */
id: string;
/** Accessible name of the group, e.g. "Package manager". */
label?: string;
active?: number;
/** Shiki themes for light and dark. */
themes?: { light: string; dark: string };
}
const { tabs, id, label, active = 0, themes = { light: 'github-light', dark: 'github-dark-default' }, class: className, style, ...rest } = Astro.props;
const list = tabs.slice(0, 8);
const vars = [`--ma-codetabs-n:${list.length}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
---
<div class:list={['ma-codetabs', className]} style={vars} {...rest}>
<div class="ma-codetabs__bar">
<div class="ma-codetabs__list" role="radiogroup" aria-label={label}>
{list.map((t, i) => (
<>
<input type="radio" name={id} id={`${id}-${i}`} class="ma-codetabs__input" checked={i === active} />
<label for={`${id}-${i}`} class="ma-codetabs__tab">{t.label}</label>
</>
))}
<span class="ma-codetabs__indicator" aria-hidden="true"></span>
</div>
<div class="ma-codetabs__copies">
{list.map((_, i) => <span class="ma-codetabs__copy"><CopyButton target={`#${id}-p${i} pre`} compact /></span>)}
</div>
</div>
<div class="ma-codetabs__panels">
{list.map((t, i) => (
<div class="ma-codetabs__panel" id={`${id}-p${i}`}>
<Code code={t.code} lang={(t.lang ?? 'sh') as never} themes={themes as never} defaultColor={false} transformers={[readableCode()]} />
</div>
))}
</div>
</div>
<style is:global>
@layer components {
:where(.ma-codetabs) {
display: grid;
/* the block's own width, not its widest line's: the tab list scrolls sideways and the code scrolls in its <pre> */
grid-template-columns: minmax(0, 1fr);
overflow: clip;
border: 1px solid var(--ma-edge);
border-radius: 0.75rem;
background: var(--ma-panel);
color: var(--ma-ink);
font-size: 0.875rem;
}
:where(.ma-codetabs__bar) {
display: flex;
align-items: stretch;
justify-content: space-between;
gap: 0.5rem;
border-bottom: 1px solid var(--ma-edge);
padding-inline-end: 0.4rem;
}
:where(.ma-codetabs__list) {
position: relative;
display: grid;
grid-auto-flow: column;
grid-auto-columns: 1fr; /* equal in an auto-sized grid: every fr track takes the widest label */
min-width: 0;
overflow-x: auto;
scrollbar-width: none;
}
:where(.ma-codetabs__input) {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
:where(.ma-codetabs__tab) {
padding: 0.7rem 0.9rem;
font-family: ui-monospace, monospace;
font-size: 0.8125em;
opacity: var(--ma-dim, 0.7);
cursor: pointer;
white-space: nowrap;
transition: opacity var(--ma-duration-fast) var(--ma-ease-out);
}
:where(.ma-codetabs__tab:hover),
:where(.ma-codetabs__input:checked + .ma-codetabs__tab) {
opacity: 1;
}
:where(.ma-codetabs__input:focus-visible + .ma-codetabs__tab) {
outline: 2px solid currentColor;
outline-offset: -3px;
border-radius: 0.375rem;
}
/* the indicator is as wide as one tab and translates by whole tabs — tabs share one width so no measuring is needed */
:where(.ma-codetabs__indicator) {
position: absolute;
inset-inline-start: 0;
bottom: 0;
width: calc(100% / var(--ma-codetabs-n, 1));
height: 2px;
background: currentColor;
translate: calc(var(--ma-codetab, 0) * 100% * var(--ma-codetabs-dir, 1)) 0;
transition: translate var(--ma-duration) var(--ma-ease);
}
/* right to left the tabs run from the right edge, so the indicator steps left. [dir] and not :dir(): :dir() reached Chrome in 120, and a build that targets an older Chrome rewrites it into a guess from lang, which a page that sets only dir never matches. An
ltr island one level inside an rtl page is set back */
:where([dir='rtl'] .ma-codetabs, .ma-codetabs[dir='rtl']) { --ma-codetabs-dir: -1; }
:where([dir='rtl'] [dir='ltr'] .ma-codetabs, [dir='rtl'] .ma-codetabs[dir='ltr']) { --ma-codetabs-dir: 1; }
:where(.ma-codetabs:has(.ma-codetabs__input:nth-of-type(2):checked)) { --ma-codetab: 1; }
:where(.ma-codetabs:has(.ma-codetabs__input:nth-of-type(3):checked)) { --ma-codetab: 2; }
:where(.ma-codetabs:has(.ma-codetabs__input:nth-of-type(4):checked)) { --ma-codetab: 3; }
:where(.ma-codetabs:has(.ma-codetabs__input:nth-of-type(5):checked)) { --ma-codetab: 4; }
:where(.ma-codetabs:has(.ma-codetabs__input:nth-of-type(6):checked)) { --ma-codetab: 5; }
:where(.ma-codetabs:has(.ma-codetabs__input:nth-of-type(7):checked)) { --ma-codetab: 6; }
:where(.ma-codetabs:has(.ma-codetabs__input:nth-of-type(8):checked)) { --ma-codetab: 7; }
:where(.ma-codetabs__copies) {
display: grid;
align-self: center;
flex: none;
}
/* nth-of-type, not nth-child: Astro renders the component's <script> tag next to the first CopyButton. The wrapper is
this component's own element (7F): a display rule on the button itself competed with CopyButton's own
`display` at equal specificity, and which one won depended on the order the bundler gave the two stylesheets. */
:where(.ma-codetabs__copy) {
grid-area: 1 / 1;
display: none;
}
:where(.ma-codetabs__copy .ma-copy) {
border-color: transparent;
background: transparent;
}
:where(.ma-codetabs__panel) {
display: none;
opacity: 1;
translate: 0 0;
transition:
opacity var(--ma-duration) var(--ma-ease),
translate var(--ma-duration) var(--ma-ease);
}
:where(.ma-codetabs:has(.ma-codetabs__input:nth-of-type(1):checked) .ma-codetabs__panel:nth-child(1)),
:where(.ma-codetabs:has(.ma-codetabs__input:nth-of-type(2):checked) .ma-codetabs__panel:nth-child(2)),
:where(.ma-codetabs:has(.ma-codetabs__input:nth-of-type(3):checked) .ma-codetabs__panel:nth-child(3)),
:where(.ma-codetabs:has(.ma-codetabs__input:nth-of-type(4):checked) .ma-codetabs__panel:nth-child(4)),
:where(.ma-codetabs:has(.ma-codetabs__input:nth-of-type(5):checked) .ma-codetabs__panel:nth-child(5)),
:where(.ma-codetabs:has(.ma-codetabs__input:nth-of-type(6):checked) .ma-codetabs__panel:nth-child(6)),
:where(.ma-codetabs:has(.ma-codetabs__input:nth-of-type(7):checked) .ma-codetabs__panel:nth-child(7)),
:where(.ma-codetabs:has(.ma-codetabs__input:nth-of-type(8):checked) .ma-codetabs__panel:nth-child(8)) {
display: block;
}
:where(.ma-codetabs:has(.ma-codetabs__input:nth-of-type(1):checked) .ma-codetabs__copy:nth-of-type(1)),
:where(.ma-codetabs:has(.ma-codetabs__input:nth-of-type(2):checked) .ma-codetabs__copy:nth-of-type(2)),
:where(.ma-codetabs:has(.ma-codetabs__input:nth-of-type(3):checked) .ma-codetabs__copy:nth-of-type(3)),
:where(.ma-codetabs:has(.ma-codetabs__input:nth-of-type(4):checked) .ma-codetabs__copy:nth-of-type(4)),
:where(.ma-codetabs:has(.ma-codetabs__input:nth-of-type(5):checked) .ma-codetabs__copy:nth-of-type(5)),
:where(.ma-codetabs:has(.ma-codetabs__input:nth-of-type(6):checked) .ma-codetabs__copy:nth-of-type(6)),
:where(.ma-codetabs:has(.ma-codetabs__input:nth-of-type(7):checked) .ma-codetabs__copy:nth-of-type(7)),
:where(.ma-codetabs:has(.ma-codetabs__input:nth-of-type(8):checked) .ma-codetabs__copy:nth-of-type(8)) {
display: inline-flex;
}
@starting-style {
:where(.ma-codetabs__panel) {
opacity: 0;
translate: 0 var(--ma-travel-hover, 4px);
}
}
:where(.ma-codetabs__panel > pre) {
margin: 0;
padding: 1rem;
overflow-x: auto;
background: transparent !important;
font-size: 0.8125rem;
line-height: 1.6;
tab-size: 2;
}
/* Shiki with defaultColor: false leaves the colours in two variables per token; --ma-dark (0 or 1 from the base tokens'
three-state rule) picks one, so the block follows system, data-theme and .dark exactly as every other token does */
:where(.ma-codetabs__panel pre span) {
color: color-mix(in srgb, var(--shiki-dark) calc(var(--ma-dark, 0) * 100%), var(--shiki-light));
}
@media (prefers-reduced-motion: reduce) {
:where(.ma-codetabs__indicator),
:where(.ma-codetabs__panel) {
transition: none;
}
}
}
</style>Props
| Prop | Type | Default | Description |
|---|---|---|---|
tabs | { label: string; code: string; lang?: string }[] | none | Up to eight tabs; lang is a Shiki language id, sh by default. |
id | string | none | Radio group name and id prefix; unique per page. |
label | string | none | Accessible name of the radio group ("Package manager"). |
active | number | 0 | Index selected on load. |
themes | { light: string; dark: string } | github-light / github-dark-default | Shiki themes; both are emitted as variables per token, each colour moved until it reads at 4.5 : 1 on its panel. |
Reduced motion
The indicator jumps; panels switch instantly.
With ClientRouter
The tabs are CSS; CopyButton rebinds through the shared runtime.
Why it is built this way
- Highlighting happens at build: the HTML carries the colours as two custom properties per token, and one rule per theme state picks one. Zero bytes of highlighter in the browser.
- Contrast is fixed at build: a transformer (lib/contrast) moves each token colour in OKLCH lightness, hue kept, until it reads at 4.5 : 1 on a panel a shade heavier than the default. As shipped, GitHub light's red and orange, and every grey comment, fall short of AA. Colours that already pass are untouched.
- The tabs are a radiogroup, not a tablist: the controls are radios, and a screen reader announces them as radios ("pnpm, radio button, 2 of 3"). label names the group.
- Every panel has its own CopyButton, shown only with its panel (:has() again), and each copies its own <pre>, so no script decides which tab is open.
- Tabs are equal-width columns in an auto-sized grid, so the indicator is one translate of whole widths; no measuring.
- The panel scrolls horizontally inside itself (min-width: 0 on the grid item, overflow-x on the pre), so a long install line never widens a 390 px page.
Replaces: CodeTabs (Fumadocs) · CodeBlock tabs (Aceternity) · Tabs + CodeBlock (shadcn). See the migration table.
Related
npx astro add moonarcA button that copies text to the clipboard: the icon turns into a check, the label says "Copied", and both come back after the ambient duration.
npx astro add moonarc
pnpm astro add moonarc
npx shadcn@latest add …/r/tabs.json
Tabs
UITabs with an indicator that slides to the selected one and panels that fade in.
Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown