Button Group

Tri State Switch

A three-position switch whose middle means "inherit from the project": built on the radiogroup pattern, where arrows move both focus and selection.

  • buttongroup
  • tri-state
  • radiogroup
  • keyboard

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/buttongroup-018?lang=en

Build notifications

Сейчас: как в проекте

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 "buttongroup-018" (Tri State Switch) 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/buttongroup-018.json

Registry item: https://vibeui.ru/r/buttongroup-018.json
Installs to: components/vibeui/buttongroup-018.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
A three-position switch whose middle means "inherit from the project": built on the radiogroup pattern, where arrows move both focus and selection.

A three-position switch on the radiogroup pattern: arrows change the selection immediately. Zero dependencies, one file, a client component.

## 3. How to use it
import { Buttongroup018 } from "@/components/vibeui/buttongroup-018"

<Buttongroup018
  options={[{ id: "on", label: "On" }, { id: "inherit", label: "Project default" }]}
  defaultValue="inherit"
  label="Build notifications"
  onChange={(id) => save(id)}
/>

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-buttongroup-018-* palette — do not swap it for your theme tokens
- role="radiogroup" with aria-checked: without them the three buttons read as three unrelated toggles
- tabIndex=0 only on the selected button — otherwise the group eats three Tab presses
- moving focus after an arrow: without it the selection travels while focus stays behind
- the equal track columns: otherwise the "index × 100%" offset misses the button
- the middle position as a meaningful value rather than "nothing selected" — a radiogroup always has a selection

## 6. You may change
- the set of positions through options
- the initial position through defaultValue
- the accessible group name through label
- the selection callback through onChange and the active text colour through accent

## 7. Rules
- The aria-labelledby id is a hard-coded string: two groups on a page would share it.
- The surface width is computed for three columns: another count needs its own divisor in the CSS.
- The component does not persist anything: saving the setting is your onChange handler's job.
- 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/buttongroup-018.json
https://vibeui.ru/r/buttongroup-018.json

Компонент самодостаточен: один файл, без зависимостей, собственная палитра в переменных --vibeui-buttongroup-018-*. Клиентский: useState хранит выбранное значение, useRef держит трек, чтобы после стрелки перевести фокус на новую кнопку. Реализован паттерн radiogroup из WAI-ARIA: кнопки с role="radio" и aria-checked, tabIndex=0 только у выбранной, стрелки в обе стороны с переносом по кругу — в radiogroup стрелка меняет выбор, а не только фокус, и это отличает её от toolbar. Подложка одна на группу: ширина равна трети трека, смещение считается как translateX(index * 100%), поэтому колонки обязаны быть равными (grid-template-columns:repeat(3,1fr)).

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 { ComponentPropsWithoutRef, CSSProperties } from "react"

export type Buttongroup018Option = {
  id: string
  label: string
}

export type Buttongroup018Props = Omit<
  ComponentPropsWithoutRef<"div">,
  "children" | "onChange"
> & {
  options?: Buttongroup018Option[]
  defaultValue?: string
  label?: string
  onChange?: (id: string) => void
  accent?: string
}

// Идея компонента: трёхпозиционный переключатель «включено / по умолчанию /
// выключено», где средняя позиция — не «ничего не выбрано», а осмысленное
// «как в настройках проекта». Реализован не на radio, а на паттерне
// radiogroup из WAI-ARIA: кнопки с role="radio" и aria-checked, стрелки
// сразу переносят и фокус, и выбор (в radiogroup это одно движение, в
// отличие от toolbar), tabIndex=0 стоит только на выбранной кнопке.
const STYLES = `
:where([data-vibeui-block="buttongroup-018"]){
--vibeui-buttongroup-018-surface:oklch(1 0 0);
--vibeui-buttongroup-018-track:oklch(0.955 0.004 265);
--vibeui-buttongroup-018-fg:oklch(0.25 0.016 265);
--vibeui-buttongroup-018-muted:oklch(0.56 0.014 265);
--vibeui-buttongroup-018-border:oklch(0.9 0.006 265);
--vibeui-buttongroup-018-accent:oklch(0.53 0.16 265);
--vibeui-buttongroup-018-radius:0.5rem;
--vibeui-buttongroup-018-index:1;
--vibeui-buttongroup-018-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}
[data-vibeui-block="buttongroup-018"]{
box-sizing:border-box;display:inline-flex;flex-direction:column;gap:0.5rem;
width:100%;max-width:22rem;padding:0.75rem;
border:1px solid var(--vibeui-buttongroup-018-border);border-radius:0.875rem;
background:var(--vibeui-buttongroup-018-surface);
font-family:var(--vibeui-buttongroup-018-font);
}
[data-vibeui-block="buttongroup-018"] *{box-sizing:border-box}
[data-vibeui-block="buttongroup-018"] [data-part="title"]{
margin:0;color:var(--vibeui-buttongroup-018-fg);
font-size:0.8125rem;font-weight:650;line-height:1.3;
}
[data-vibeui-block="buttongroup-018"] [data-part="track"]{
position:relative;display:grid;grid-template-columns:repeat(3,1fr);
padding:0.1875rem;
border-radius:calc(var(--vibeui-buttongroup-018-radius) + 0.1875rem);
background:var(--vibeui-buttongroup-018-track);
}
[data-vibeui-block="buttongroup-018"] [data-part="thumb"]{
position:absolute;top:0.1875rem;bottom:0.1875rem;left:0.1875rem;
width:calc((100% - 0.375rem) / 3);
border-radius:var(--vibeui-buttongroup-018-radius);
background:var(--vibeui-buttongroup-018-surface);
box-shadow:0 1px 2px oklch(0.2 0.02 265 / 18%);
transform:translateX(calc(var(--vibeui-buttongroup-018-index) * 100%));
transition:transform .2s cubic-bezier(.2,.7,.3,1);
}
[data-vibeui-block="buttongroup-018"] button{
appearance:none;border:0;background:transparent;font:inherit;cursor:pointer;
position:relative;z-index:1;
display:inline-flex;align-items:center;justify-content:center;
height:2rem;padding:0 0.25rem;border-radius:var(--vibeui-buttongroup-018-radius);
color:var(--vibeui-buttongroup-018-muted);
font-size:0.8125rem;font-weight:600;line-height:1;
transition:color .18s ease;
}
[data-vibeui-block="buttongroup-018"] button[aria-checked="true"]{color:var(--vibeui-buttongroup-018-accent)}
[data-vibeui-block="buttongroup-018"] button:focus-visible{
outline:2px solid var(--vibeui-buttongroup-018-accent);outline-offset:2px;
}
[data-vibeui-block="buttongroup-018"] [data-part="state"]{
margin:0;color:var(--vibeui-buttongroup-018-muted);
font-size:0.75rem;line-height:1.4;
}
[data-vibeui-block="buttongroup-018"] [data-part="state"] b{
color:var(--vibeui-buttongroup-018-fg);font-weight:650;
}
@media (prefers-reduced-motion:reduce){[data-vibeui-block="buttongroup-018"] *{animation:none!important;transition:none!important}}
`

const DEFAULT_OPTIONS: Buttongroup018Option[] = [
  { id: "on", label: "Включено" },
  { id: "inherit", label: "Как в проекте" },
  { id: "off", label: "Выключено" },
]

/**
 * Трёхпозиционный переключатель на паттерне radiogroup со стрелками.
 * Один файл, ноль зависимостей, собственная палитра.
 */
export function Buttongroup018({
  options = DEFAULT_OPTIONS,
  defaultValue = "inherit",
  label = "Уведомления о сборке",
  onChange,
  accent,
  className,
  style,
  ...props
}: Buttongroup018Props) {
  const [current, setCurrent] = useState(defaultValue)
  const track = useRef<HTMLDivElement>(null)
  const index = Math.max(
    0,
    options.findIndex((option) => option.id === current),
  )

  const select = (next: string, position: number) => {
    setCurrent(next)
    onChange?.(next)
    const buttons = track.current?.querySelectorAll("button")
    buttons?.[position]?.focus()
  }

  const palette = {
    "--vibeui-buttongroup-018-index": index,
    ...(accent ? { "--vibeui-buttongroup-018-accent": accent } : null),
    ...style,
  } as CSSProperties

  return (
    <>
      <style href="vibeui-buttongroup-018" precedence="medium">
        {STYLES}
      </style>
      <div
        {...props}
        data-vibeui-block="buttongroup-018"
        className={className}
        style={palette}
      >
        <p data-part="title" id="buttongroup-018-title">
          {label}
        </p>
        <div
          data-part="track"
          ref={track}
          role="radiogroup"
          aria-labelledby="buttongroup-018-title"
          onKeyDown={(event) => {
            const step =
              event.key === "ArrowRight" || event.key === "ArrowDown"
                ? 1
                : event.key === "ArrowLeft" || event.key === "ArrowUp"
                  ? -1
                  : 0

            if (step === 0) {
              return
            }

            event.preventDefault()
            const next = (index + step + options.length) % options.length
            select(options[next].id, next)
          }}
        >
          <span data-part="thumb" aria-hidden="true" />
          {options.map((option, position) => (
            <button
              key={option.id}
              type="button"
              role="radio"
              aria-checked={option.id === current}
              tabIndex={option.id === current ? 0 : -1}
              onClick={() => select(option.id, position)}
            >
              {option.label}
            </button>
          ))}
        </div>
        <p data-part="state">
          Сейчас: <b>{options[index]?.label.toLowerCase()}</b>
        </p>
      </div>
    </>
  )
}