# Expand Tabs

> A row of square icon tiles for navigation: the one under the pointer widens, fills with the accent and trades its icon for its label.

- Page: https://ui.devigner.cc/components/expand-tabs
- Category: Interface
- Requirements: React 18 or 19, Tailwind CSS v4
- Install: `npx devignerui add expand-tabs` (writes components/ui/expand-tabs.tsx)
- Packages it needs: cn, motion, devignerui

## What the demo shows

The live demo is a row of four tiles: Commands (with a dot on its corner), About, Portfolio and Shortcuts. Move the pointer onto one and it widens, fills with the accent and its icon blurs out while the label blurs in; the tiles beside it slide over to make room. Leave and it closes back to a square. Click a tile and it becomes the selected tab: the dot shrinks off Commands and grows on it, and the tile keeps its resting look.

## When to use it

- A compact icon bar where the names can wait until someone points at a tile, such as a portfolio header or a floating toolbar of a few sections.
- Three to six links of similar weight, each with a recognizable icon.

## When to reach for something else

- Primary navigation that has to be scanned at a glance or used mostly on touch screens. Labels show only on hover or keyboard focus; use visible tabs.
- Many links. Each tile is 72px and opens wider, so a long row gets crowded; use a menu.

## Usage

```tsx
import { ExpandTabs } from "@/components/ui/expand-tabs";

<ExpandTabs items={items} />
```

Custom composition:

```tsx
<ExpandTabs items={items} label="Sections" color="#7c3aed" />
```

### Portfolio header

Four sections, one marked new.

```tsx
import { IconCommand, IconHome, IconKeyboard, IconUser } from "@devigner-ui/icons";
import { ExpandTabs } from "@/components/ui/expand-tabs";

const items = [
  { label: "Home", icon: <IconHome />, href: "#home" },
  { label: "About", icon: <IconUser />, href: "#about" },
  { label: "Shortcuts", icon: <IconCommand />, href: "#shortcuts", badge: "New" },
  { label: "Contact", icon: <IconKeyboard />, href: "#contact" },
];

export function Header() {
  return <ExpandTabs items={items} label="Sections" />;
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| items | `{ label, icon, href?, onSelect?, badge? }[]` | - | The tabs, one tile each. An item with href renders a link, otherwise a button. badge shows a dot on the tile's corner and is read by screen readers, such as "New". Once a tab is selected the dot moves to the selected tile. |
| label | `string` | Navigation | Accessible name for the nav landmark. |
| color | `string` | #2152ff | Fill of the open tile and the badge dot, any CSS color. The open tile's text is white, so pick a color dark enough to carry it. |
| classNames | `ExpandTabsClassNames` | - | Slot classes: tab (the tile), active (the fill layer that fades in on the open tile), label (white by default) and badge. |
| value | `number \| null` | - | Index of the selected tab. It is marked by the badge dot alone and keeps the tile's resting look; it widens to its label on hover like the rest. Pass with onValueChange to control it. |
| defaultValue | `number \| null` | null | Selected tab on first render when uncontrolled. Clicking a tab selects it. |
| onValueChange | `(index: number) => void` | - | Called with the tab's index when it is clicked. |

## Keyboard

| Keys | Action |
| --- | --- |
| Tab / Shift+Tab | Moves between tiles; the focused tile opens and shows its label with a focus ring. |
| Enter | Selects the tile and follows the link, or runs onSelect when the item has no href. Space does the same on a button. |

## Accessibility

- Renders a nav landmark named by label, with a list of real links (or buttons for items without href).
- The label stays in the DOM while hidden, so every tile is named by its label; the icon is aria-hidden.
- A badge is drawn as an aria-hidden dot, and its text is added to the tile's name for screen readers.
- The selected tile carries aria-current (page on a link, true on a button). Keyboard focus also opens a tile; a click's focus does not. At rest the selection is shown by the dot alone, so a sighted user relies on that dot.
- Honors prefers-reduced-motion and an ancestor <MotionConfig reducedMotion="always">, so an in-app motion switch works too.

## Theming

- A closed tile is bg-foreground/8 with text-foreground icons, so it follows the theme.
- The open tile and the badge dot are filled with color (#2152ff by default) and the open tile's text is white in both themes. Pass a darker color if yours is light.
- classNames targets tab, active, label and badge. Put placement classes in className.

## Edge cases

- An open tile is as wide as its label plus 28px either side, and never narrower than the 72px square. The width is measured, so a font that loads late resizes it.
- The row grows around the open tile, so the tiles beside it move; center the bar (or anchor its start) so it shifts the way you want.
- Icons are sized 24px unless the svg has its own size-* class.
- The badge dot is cut out of the tile with a 5px gap that shows whatever sits behind the bar, and the notch is rounded where it meets the tile's top and right edges. The cutout is sized for the 14px dot, so resizing the dot through classNames.badge leaves the gap off.
- A tile opens when the pointer moves over it, not when the pointer enters it. Opening reflows the row and slides tiles under a still pointer; opening on enter would flip two tiles back and forth for as long as the pointer rests near their edge.
- There is one dot: on the first item with a badge until a tab is selected, then on the selected tile. The badge text stays with its own item for screen readers.
- value and defaultValue are indexes into items. With no selection (the default) the dot stays on the first badged item.
- Items are keyed by href, then label, so two items with neither need distinct labels.

## Troubleshooting

**The tile does not open on a phone.**
There is no hover on touch screens, so a tap follows the link straight away. Pick visible tabs when most visitors are on touch.

**The component renders with no background or the wrong colors.**
Colors use the standard shadcn/ui tokens only (background, foreground, primary, secondary, muted, accent, border, input, ring, destructive), so a project set up with shadcn/ui needs nothing extra and the component follows its theme, dark mode included. Without shadcn/ui, define those CSS variables for :root and .dark and map them in your Tailwind v4 @theme, or run npx shadcn init.
