# ASCII tabs

Tabs drawn as box-drawing folder tabs or a sliding glyph underline, with arrow-key navigation and a roving tab stop.

> For the complete documentation index, see [llms.txt](/llms.txt). Markdown variants are available by appending `.md` to any URL or sending an `Accept: text/markdown` header. The asciicn agent skill, for building any ASCII effect on the engine, is at [/.well-known/agent-skills/asciicn/SKILL.md](/.well-known/agent-skills/asciicn/SKILL.md).

> **This is an example, not the only option.** It is one component built on the asciicn engine. Install it when it is what you were asked for. For any other look, don't stretch its props to fit: write your own field on `AsciiShader` ([Write your own](/docs/engine/write-your-own.md)), or draw a CPU grid with `AsciiGrid`, and read this example's source for technique. Its full source is in [https://asciicn.dev/r/ascii-tabs.json](https://asciicn.dev/r/ascii-tabs.json), under `files[].content`.

## Installation [#installation]

```bash
npx shadcn@latest add https://asciicn.dev/r/ascii-tabs.json
```

## Usage [#usage]

```tsx
import { AsciiTabs } from "@/components/asciicn/ascii-tabs";

export function ProjectTabs() {
  return (
    <AsciiTabs
      label="Project"
      tabs={[
        { value: "overview", label: "Overview", content: <Overview /> },
        { value: "deploys", label: "Deploys", content: <Deploys /> },
      ]}
    />
  );
}
```

Each tab has a `value`, a plain-text `label` and the `content` of its panel. Labels are plain strings because every tab is a whole number of glyph cells wide: the label plus a cell of space each side, with a divider cell between tabs.

The `folder` variant lifts the active tab out of a framed panel, like a file folder:

```text
            ╭──────────╮
  Overview  │ Deploys  │ Settings
╭───────────╯          ╰──────────────╮
│                                     │
```

When you pick another tab, its lid and the opening under it slide over in whole cells for 200ms, and the sides close once they land.

The `underline` variant runs a light rule `─` under the tabs with a heavy `━` under the active one. It slides in half-cell steps, using `╾` and `╼` where it ends halfway through a cell.

The tabs control themselves, starting from `defaultValue` or the first tab. To control them, pass `value` and `onValueChange`.

## Examples [#examples]

### Install command [#install-command]

The `underline` variant in green, switching between package managers.

```tsx title="examples/ascii-tabs-install.tsx"
import { AsciiTabs } from "@/components/asciicn/ascii-tabs";
import type { AsciiTab } from "@/components/asciicn/ascii-tabs";

const URL = "https://asciicn.dev/r/ascii-tabs.json";

const MANAGERS = [
  ["pnpm", `pnpm dlx shadcn@latest add ${URL}`],
  ["npm", `npx shadcn@latest add ${URL}`],
  ["yarn", `yarn dlx shadcn@latest add ${URL}`],
  ["bun", `bunx --bun shadcn@latest add ${URL}`],
] as const;

const TABS: readonly AsciiTab[] = MANAGERS.map(([name, command]) => ({
  content: (
    <code className="text-muted-foreground block font-mono text-xs break-all">
      <span className="text-foreground">$ </span>
      {command}
    </code>
  ),
  label: name,
  value: name,
}));

export default function AsciiTabsInstall() {
  return (
    <AsciiTabs
      tabs={TABS}
      label="Package manager"
      variant="underline"
      color="green"
      className="max-w-md"
    />
  );
}
```

### Controlled [#controlled]

`value` and `onValueChange` from `useState`, at `size="sm"`.

```tsx title="examples/ascii-tabs-controlled.tsx"
"use client";

import { useState } from "react";

import { AsciiTabs } from "@/components/asciicn/ascii-tabs";
import type { AsciiTab } from "@/components/asciicn/ascii-tabs";

const ROUTES = ["/", "/docs", "/studio", "/backgrounds"] as const;

const TABS: readonly AsciiTab[] = [
  {
    content: (
      <p className="text-muted-foreground text-sm">
        412 errors in the last 24 hours, most from{" "}
        <span className="text-foreground font-mono">/studio</span>.
      </p>
    ),
    label: "Errors",
    value: "errors",
  },
  {
    content: (
      <ul className="text-muted-foreground flex flex-col gap-1 font-mono text-xs">
        {ROUTES.map((route) => (
          <li key={route}>GET {route} 200</li>
        ))}
      </ul>
    ),
    label: "Requests",
    value: "requests",
  },
  {
    content: (
      <p className="text-muted-foreground text-sm">
        No slow queries this week.
      </p>
    ),
    label: "Queries",
    value: "queries",
  },
];

export default function AsciiTabsControlled() {
  const [tab, setTab] = useState("requests");
  return (
    <div className="flex w-full max-w-md flex-col gap-3">
      <AsciiTabs
        tabs={TABS}
        value={tab}
        onValueChange={setTab}
        label="Logs"
        color="blue"
        size="sm"
      />
      <p className="text-muted-foreground font-mono text-xs">
        value: <span className="text-foreground">{`"${tab}"`}</span>
      </p>
    </div>
  );
}
```

## Props [#props]

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `tabs` (required) | `readonly AsciiTab[]` |  | { value: string; label: string; content: ReactNode }, left to right. |
| `value` | `string` |  | The active tab, when you control it. |
| `defaultValue` | `string` | `The first tab` | The tab active at first, when the tabs control themselves. |
| `onValueChange` | `(value: string) => void` |  | Called with the new tab's value when the visitor picks a tab. |
| `label` | `string` |  | Names the tab list for screen readers. |
| `variant` | `"folder" \| "underline"` | `"folder"` | Folder tabs over a framed panel, or a sliding underline. |
| `color` | `AsciiColor` | `"ink"` | Colour of the active label and the underline, a palette name or any CSS colour. |
| `size` | `"sm" \| "md" \| "lg"` | `"md"` | Glyph cell height: 12, 16 or 20px. |
| `cell` | `number` |  | Glyph cell height in CSS pixels. Overrides size. |

## Notes [#notes]

* The tabs follow the ARIA tabs pattern. The list has one tab stop, on the active tab. Left and Right arrows move between tabs and wrap at the ends, Home and End jump to the first and last, and the panel follows focus.
* Each panel is a `tabpanel` named by its tab and is a tab stop itself, so keyboard users can reach a panel with no controls in it. Inactive panels stay in the page with `hidden`.
* Each tab's hit area reaches over the frame rows above and below its label, so a 16px row is still easy to tap.
* The frame is DOM text in `--ascii-font` and hidden from screen readers. With reduced motion, the lid and the underline jump to the new tab.
* The strip is as wide as its tabs. Keep labels short on narrow screens: three tabs of eight letters need about 34 cells.