Navigation

Counter Tabs

Mail folder tabs: a glyph, a label and a counter in one row, with the number folded into the tab's name rather than read as a bare digit.

  • tabs
  • icons
  • counters
  • mail

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-006?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-006" (Counter 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-006.json

Registry item: https://vibeui.ru/r/tabs-006.json
Installs to: components/vibeui/tabs-006.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
Mail folder tabs: a glyph, a label and a counter in one row, with the number folded into the tab's name rather than read as a bare digit.

Tabs with glyphs and counters: the label beside the glyph, the unread count on the right, and the active tab's counter filled with the accent. The glyphs are drawn with borders, so the file stays self-contained. Zero dependencies, one file, its own palette.

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

<Tabs006
  defaultId="star"
  items={[
    { id: "inbox", label: "Inbox", glyph: "inbox", count: 12, content: "…" },
  ]}
/>

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-006-* palette — do not swap it for your theme tokens (bg-muted, text-foreground and the like)
- the count inside the tab's aria-label with the badge aria-hidden: otherwise a screen reader reads the label and a bare number back to back
- the labels beside the glyphs: a wordless glyph gets guessed wrong, especially in mail
- the WAI-ARIA tabs markup: role="tablist", role="tab" with aria-selected and aria-controls, role="tabpanel" with aria-labelledby
- glyphs from borders and clip-path instead of an icon font — the component has to stay a single file
- tabular figures in the counter: without them the tab width jitters with the numbers

## 6. You may change
- the items array: labels, glyphs, counts and panel content
- defaultId — which folder starts open
- the glyph set: swap in your own, keeping the 0.875rem size
- the accent through the accent prop — it colours the underline and the active counter

## 7. Rules
- A counter means something needing attention — unread, failing. A total message count does not belong there.
- The component is a client one: "use client" is there for the state and the keyboard.
- The glyphs are schematic: for recognisable icons drop in your own set.
- 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-006.json
https://vibeui.ru/r/tabs-006.json

Компонент самодостаточен: один файл, без зависимостей, собственная палитра в локальных переменных --vibeui-tabs-006-*. Клиентский: "use client" ради выбранной вкладки и клавиатуры. Значки нарисованы бордюрами и clip-path по атрибуту data-glyph, поэтому иконочный шрифт не нужен. Счётчик помечен aria-hidden, а число попадает в aria-label вкладки. Разметка следует паттерну tabs из WAI-ARIA, ряд прокручивается по горизонтали со скрытой полосой прокрутки.

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 Tabs006Item = {
  id: string
  label: string
  glyph?: "inbox" | "star" | "clock" | "trash"
  count?: number
  content?: ReactNode
}

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

// Идея компонента: вкладки почтовых папок — значок, подпись и счётчик в одной
// строке. Значки нарисованы бордюрами, а не иконочным шрифтом, поэтому файл
// остаётся самодостаточным. Число уходит в aria-label вкладки: сам счётчик
// скрыт от скринридера, иначе он читается как продолжение подписи.
const STYLES = `
:where([data-vibeui-block="tabs-006"]){
--vibeui-tabs-006-bg:oklch(1 0 0);
--vibeui-tabs-006-fg:oklch(0.22 0.014 265);
--vibeui-tabs-006-muted:oklch(0.55 0.014 265);
--vibeui-tabs-006-border:oklch(0.91 0.006 265);
--vibeui-tabs-006-chip:oklch(0.94 0.004 265);
--vibeui-tabs-006-accent:oklch(0.55 0.2 262);
--vibeui-tabs-006-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}
[data-vibeui-block="tabs-006"]{
box-sizing:border-box;width:100%;max-width:30rem;padding:0.5rem 0.75rem 0.875rem;
background:var(--vibeui-tabs-006-bg);color:var(--vibeui-tabs-006-fg);
border:1px solid var(--vibeui-tabs-006-border);border-radius:0.875rem;
font-family:var(--vibeui-tabs-006-font);
}
[data-vibeui-block="tabs-006"] [data-part="list"]{
display:flex;gap:0.25rem;overflow-x:auto;scrollbar-width:none;
border-bottom:1px solid var(--vibeui-tabs-006-border);
}
[data-vibeui-block="tabs-006"] [data-part="list"]::-webkit-scrollbar{display:none}
[data-vibeui-block="tabs-006"] [data-part="tab"]{
position:relative;appearance:none;border:0;background:none;cursor:pointer;
display:inline-flex;align-items:center;gap:0.4375rem;white-space:nowrap;
padding:0.5rem 0.5rem 0.625rem;margin-bottom:-1px;
font:inherit;font-size:0.8125rem;font-weight:500;color:var(--vibeui-tabs-006-muted);
}
[data-vibeui-block="tabs-006"] [data-part="tab"]:hover{color:var(--vibeui-tabs-006-fg)}
[data-vibeui-block="tabs-006"] [data-part="tab"]:focus-visible{outline:2px solid var(--vibeui-tabs-006-accent);outline-offset:-3px;border-radius:0.375rem}
[data-vibeui-block="tabs-006"] [data-part="tab"][aria-selected="true"]{color:var(--vibeui-tabs-006-fg);font-weight:650}
[data-vibeui-block="tabs-006"] [data-part="tab"][aria-selected="true"]::after{
content:"";position:absolute;left:0.375rem;right:0.375rem;bottom:0;height:2px;
border-radius:2px 2px 0 0;background:var(--vibeui-tabs-006-accent);
}
/* Значки собраны из бордюров: набор иконок сюда не тянется. */
[data-vibeui-block="tabs-006"] [data-part="glyph"]{
width:0.875rem;height:0.875rem;flex:none;position:relative;
border:1.5px solid currentColor;box-sizing:border-box;
}
[data-vibeui-block="tabs-006"] [data-part="glyph"][data-glyph="inbox"]{border-radius:0.1875rem}
[data-vibeui-block="tabs-006"] [data-part="glyph"][data-glyph="inbox"]::after{
content:"";position:absolute;left:-1.5px;right:-1.5px;top:55%;height:1.5px;background:currentColor;
}
[data-vibeui-block="tabs-006"] [data-part="glyph"][data-glyph="star"]{
border:0;background:currentColor;
clip-path:polygon(50% 0,61% 35%,98% 35%,68% 57%,79% 91%,50% 70%,21% 91%,32% 57%,2% 35%,39% 35%);
}
[data-vibeui-block="tabs-006"] [data-part="glyph"][data-glyph="clock"]{border-radius:999px}
[data-vibeui-block="tabs-006"] [data-part="glyph"][data-glyph="clock"]::after{
content:"";position:absolute;left:50%;top:25%;width:1.5px;height:32%;background:currentColor;
}
[data-vibeui-block="tabs-006"] [data-part="glyph"][data-glyph="trash"]{border-radius:0 0 0.1875rem 0.1875rem;border-top-width:3px}
[data-vibeui-block="tabs-006"] [data-part="count"]{
min-width:1.125rem;padding:0 0.25rem;box-sizing:border-box;
border-radius:999px;background:var(--vibeui-tabs-006-chip);
font-size:0.6875rem;line-height:1.125rem;text-align:center;
font-variant-numeric:tabular-nums;color:var(--vibeui-tabs-006-fg);
}
[data-vibeui-block="tabs-006"] [data-part="tab"][aria-selected="true"] [data-part="count"]{
background:var(--vibeui-tabs-006-accent);color:oklch(1 0 0);
}
[data-vibeui-block="tabs-006"] [data-part="panel"]{
padding-top:0.875rem;font-size:0.875rem;line-height:1.6;color:var(--vibeui-tabs-006-muted);
}
[data-vibeui-block="tabs-006"] [data-part="panel"]:focus-visible{outline:2px solid var(--vibeui-tabs-006-accent);outline-offset:3px;border-radius:0.5rem}
@media (prefers-reduced-motion:reduce){[data-vibeui-block="tabs-006"] *{animation:none!important;transition:none!important}}
`

const DEFAULT_ITEMS: Tabs006Item[] = [
  {
    id: "inbox",
    label: "Входящие",
    glyph: "inbox",
    count: 12,
    content: "Двенадцать непрочитанных писем, три из них помечены как срочные.",
  },
  {
    id: "star",
    label: "Важное",
    glyph: "star",
    count: 3,
    content: "Письма, отмеченные звёздочкой вручную или правилом фильтра.",
  },
  {
    id: "later",
    label: "Отложенные",
    glyph: "clock",
    count: 5,
    content: "Вернутся во входящие в указанное время и снова станут заметными.",
  },
  {
    id: "trash",
    label: "Корзина",
    glyph: "trash",
    content: "Удалённое хранится тридцать дней, потом исчезает окончательно.",
  },
]

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

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

  const index = Math.max(
    0,
    items.findIndex((item) => item.id === active),
  )

  function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {
    const keys = ["ArrowLeft", "ArrowRight", "Home", "End"]

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

    event.preventDefault()

    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[index]

  return (
    <>
      <style href="vibeui-tabs-006" precedence="medium">
        {STYLES}
      </style>
      <div data-vibeui-block="tabs-006" className={className} style={palette}>
        <div
          data-part="list"
          role="tablist"
          aria-label="Папки"
          ref={listRef}
          onKeyDown={onKeyDown}
        >
          {items.map((item) => (
            <button
              key={item.id}
              type="button"
              data-part="tab"
              role="tab"
              id={`vibeui-tabs-006-${item.id}-tab`}
              aria-selected={item.id === active}
              aria-controls={`vibeui-tabs-006-${item.id}-panel`}
              aria-label={
                item.count === undefined
                  ? undefined
                  : `${item.label}, писем ${item.count}`
              }
              tabIndex={item.id === active ? 0 : -1}
              onClick={() => setActive(item.id)}
            >
              <span
                data-part="glyph"
                data-glyph={item.glyph ?? "inbox"}
                aria-hidden="true"
              />
              {item.label}
              {item.count === undefined ? null : (
                <span data-part="count" aria-hidden="true">
                  {item.count}
                </span>
              )}
            </button>
          ))}
        </div>
        {current ? (
          <div
            data-part="panel"
            role="tabpanel"
            id={`vibeui-tabs-006-${current.id}-panel`}
            aria-labelledby={`vibeui-tabs-006-${current.id}-tab`}
            tabIndex={0}
          >
            {current.content}
          </div>
        ) : null}
      </div>
    </>
  )
}