Navigation

Keyboard Tabs

Tabs that answer the keyboard the way system tabs do: arrows move the selection, Home and End jump to the ends. The underline travels with the active tab.

  • tabs
  • navigation
  • keyboard
  • aria

Preview

1440pxHost theme

Use it with AI

  1. 1. Copy the link.
  2. 2. Write to your agent in your own words and drop the link into the sentence.
  3. 3. The agent opens the link and installs the component from the registry.

put this in the header: https://vibeui.ru/c/tabs-001?lang=en

Сводка по проекту: адрес, дата последней публикации и размер сборки.
Show the full instructions

What the link says, spelled out. Use it when your agent cannot open links — paste this text instead.

# Install and place "tabs-001" (Keyboard Tabs) from VibeUI

## 1. Install first — do not skip, do not recreate
Run this exact command before writing any code:
npx shadcn@latest add https://vibeui.ru/r/tabs-001.json

Registry item: https://vibeui.ru/r/tabs-001.json
Installs to: components/vibeui/tabs-001.tsx (the exact path follows this project's components.json aliases).
npm dependencies: none.
Registry dependencies: none.

Install it from the registry. Do not recreate it from the description,
do not substitute a similar component from another library, and do not
rewrite it to match the project's existing style.

## 2. What it is
Tabs that answer the keyboard the way system tabs do: arrows move the selection, Home and End jump to the ends. The underline travels with the active tab.

Tabs with full keyboard support: left and right arrows move the selection in a loop, Home and End jump to the ends, and only the active tab stays in the Tab order. The underline animates with transform. The list scrolls horizontally when there are many tabs. One client component, zero dependencies, its own palette.

## 3. How to use it
import { Tabs001 } from "@/components/vibeui/tabs-001"

<Tabs001
  items={[
    { id: "overview", label: "Overview", content: <Overview /> },
    { id: "pages", label: "Pages", content: <Pages /> },
  ]}
/>

Read the installed file for the full prop list.

## 4. Where to place it
This is a small inline component. Put it exactly where the user asked,
inside the existing markup. Do not create a new page, section or
wrapper for it. If a similar control already sits in that spot,
replace it instead of adding a second one.

Placement: ___
(The user fills this line in. If it is still blank, ask where to put it
instead of guessing.)

## 5. Keep exactly as installed
- the local --vibeui-tabs-001-* palette — do not swap it for your theme tokens (bg-muted, text-muted-foreground and the like)
- the WAI-ARIA tabs markup: role="tablist", role="tab" with aria-selected and aria-controls, role="tabpanel" with aria-labelledby
- the roving tabindex: 0 on the active tab and -1 on the rest, otherwise Tab walks through every tab
- the arrow, Home and End handling together with moving focus: without it the selection changes while focus stays behind
- tabIndex={0} on the panel: its content can scroll and has to be reachable from the keyboard
- the underline as each tab's own ::after rather than one shared slider positioned from JS
- the horizontal scrolling of the list with its hidden scrollbar
- the <style> block inside the component — it holds the palette, the layout and the underline animation

## 6. You may change
- the items array: id, label and panel content — content is a ReactNode
- defaultId — which tab starts open
- the accent through the accent prop — it colours the underline and the focus ring
- width and outer spacing through className

## 7. Rules
- The component is a client one: "use client" is there for the state and the keyboard handling.
- Do not use tabs to navigate between pages: they switch content within one. Pages need links.
- Do not hide things that must be compared behind tabs: comparison requires seeing both at once.
- Panel ids are built from the tab id. Two instances sharing ids produce duplicate ids in the document — give them different ones.
- Do not lift the CSS variables into globals.css: the component has to stay a single file.

## 8. Verify
- it renders with no console errors;
- it looks like the preview on the VibeUI page you copied this from;
- if it does not, you changed something listed in section 5 — put it back.

For developers

npx shadcn@latest add https://vibeui.ru/r/tabs-001.json
https://vibeui.ru/r/tabs-001.json

Компонент самодостаточен: один файл, без зависимостей, собственная палитра в локальных переменных --vibeui-tabs-001-*. Разметка следует паттерну tabs из WAI-ARIA: role="tablist", role="tab" с aria-selected и aria-controls, role="tabpanel" с aria-labelledby. В Tab-порядке живёт только активная вкладка (roving tabindex), остальные переключаются стрелками. Полоска под вкладкой — собственный ::after каждой вкладки, ширина берётся от текста, поэтому ничего не измеряется в JS.

Component source

The same file your agent installs. Here in case you would rather copy it by hand.

"use client"

import { useRef, useState } from "react"
import type { CSSProperties, KeyboardEvent, ReactNode } from "react"

export type Tabs001Item = {
  id: string
  label: string
  content?: ReactNode
}

export type Tabs001Props = {
  items?: Tabs001Item[]
  defaultId?: string
  accent?: string
  className?: string
  style?: CSSProperties
}

// Идея компонента: вкладки, которые слушаются клавиатуры так же, как
// системные. Стрелки переводят выбор, Home и End прыгают к краям, а полоска
// под активной вкладкой едет за ней — по разметке WAI-ARIA tabs, а не по
// набору div'ов с onClick.
const STYLES = `
:where([data-vibeui-block="tabs-001"]){
--vibeui-tabs-001-fg:oklch(0.24 0.016 265);
--vibeui-tabs-001-muted:oklch(0.52 0.014 265);
--vibeui-tabs-001-border:oklch(0.9 0.006 265);
--vibeui-tabs-001-accent:oklch(0.55 0.2 262);
--vibeui-tabs-001-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
container-type:inline-size;
}
[data-vibeui-block="tabs-001"]{
display:flex;flex-direction:column;width:100%;box-sizing:border-box;
font-family:var(--vibeui-tabs-001-font);color:var(--vibeui-tabs-001-fg);
}
[data-vibeui-block="tabs-001"] [data-part="list"]{
display:flex;gap:0.25rem;overflow-x:auto;
border-bottom:1px solid var(--vibeui-tabs-001-border);
scrollbar-width:none;
}
[data-vibeui-block="tabs-001"] [data-part="list"]::-webkit-scrollbar{display:none}
[data-vibeui-block="tabs-001"] [data-part="tab"]{
appearance:none;border:0;background:transparent;cursor:pointer;
position:relative;white-space:nowrap;
padding:0.625rem 0.75rem;margin-bottom:-1px;
font:inherit;font-size:0.875rem;font-weight:500;
color:var(--vibeui-tabs-001-muted);
transition:color .16s ease;
}
[data-vibeui-block="tabs-001"] [data-part="tab"]:hover{color:var(--vibeui-tabs-001-fg)}
[data-vibeui-block="tabs-001"] [data-part="tab"]:focus-visible{
outline:2px solid var(--vibeui-tabs-001-accent);outline-offset:-2px;border-radius:0.375rem;
}
[data-vibeui-block="tabs-001"] [data-part="tab"][aria-selected="true"]{color:var(--vibeui-tabs-001-fg)}
/* Полоска под активной вкладкой: у каждой своя, ширина берётся от текста. */
[data-vibeui-block="tabs-001"] [data-part="tab"]::after{
content:"";position:absolute;left:0.75rem;right:0.75rem;bottom:0;height:2px;
border-radius:2px 2px 0 0;background:var(--vibeui-tabs-001-accent);
transform:scaleX(0);transform-origin:center;
transition:transform .18s cubic-bezier(.32,.72,0,1);
}
[data-vibeui-block="tabs-001"] [data-part="tab"][aria-selected="true"]::after{transform:scaleX(1)}
[data-vibeui-block="tabs-001"] [data-part="panel"]{
padding:1rem 0.125rem;font-size:0.9375rem;line-height:1.6;color:var(--vibeui-tabs-001-muted);
}
[data-vibeui-block="tabs-001"] [data-part="panel"]:focus-visible{outline:2px solid var(--vibeui-tabs-001-accent);outline-offset:4px;border-radius:0.5rem}
@media (prefers-reduced-motion:reduce){[data-vibeui-block="tabs-001"] *{animation:none!important;transition:none!important}}
`

const DEFAULT_ITEMS: Tabs001Item[] = [
  {
    id: "overview",
    label: "Обзор",
    content:
      "Сводка по проекту: адрес, дата последней публикации и размер сборки.",
  },
  {
    id: "pages",
    label: "Страницы",
    content: "Список страниц с датами изменения и статусом публикации.",
  },
  {
    id: "domains",
    label: "Домены",
    content: "Подключённые домены, сертификаты и записи DNS.",
  },
]

/**
 * Вкладки с полной клавиатурой: стрелки, Home и End.
 * Один файл, ноль зависимостей, собственная палитра.
 */
export function Tabs001({
  items = DEFAULT_ITEMS,
  defaultId,
  accent,
  className,
  style,
}: Tabs001Props) {
  const [active, setActive] = useState(defaultId ?? items[0]?.id)
  const listRef = useRef<HTMLDivElement>(null)

  const palette = {
    ...(accent ? { "--vibeui-tabs-001-accent": accent } : null),
    ...style,
  } as CSSProperties

  // Стрелки двигают выбор по кругу и переносят фокус: так ведут себя вкладки
  // в системных интерфейсах, и это часть паттерна tabs в WAI-ARIA.
  function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {
    const keys = ["ArrowLeft", "ArrowRight", "Home", "End"]

    if (!keys.includes(event.key)) {
      return
    }

    event.preventDefault()

    const index = items.findIndex((item) => item.id === active)
    const last = items.length - 1
    const next =
      event.key === "Home"
        ? 0
        : event.key === "End"
          ? last
          : event.key === "ArrowLeft"
            ? (index - 1 + items.length) % items.length
            : (index + 1) % items.length

    setActive(items[next].id)
    listRef.current
      ?.querySelectorAll<HTMLButtonElement>('[data-part="tab"]')
      [next]?.focus()
  }

  const current = items.find((item) => item.id === active) ?? items[0]

  return (
    <>
      <style href="vibeui-tabs-001" precedence="medium">
        {STYLES}
      </style>
      <div data-vibeui-block="tabs-001" className={className} style={palette}>
        <div
          data-part="list"
          role="tablist"
          ref={listRef}
          onKeyDown={onKeyDown}
        >
          {items.map((item) => (
            <button
              key={item.id}
              data-part="tab"
              type="button"
              role="tab"
              id={`vibeui-tabs-001-${item.id}-tab`}
              aria-selected={item.id === active}
              aria-controls={`vibeui-tabs-001-${item.id}-panel`}
              tabIndex={item.id === active ? 0 : -1}
              onClick={() => setActive(item.id)}
            >
              {item.label}
            </button>
          ))}
        </div>
        {current ? (
          <div
            data-part="panel"
            role="tabpanel"
            id={`vibeui-tabs-001-${current.id}-panel`}
            aria-labelledby={`vibeui-tabs-001-${current.id}-tab`}
            tabIndex={0}
          >
            {current.content}
          </div>
        ) : null}
      </div>
    </>
  )
}