Button Group

Segmented Filter

A segmented control built on real radio inputs: the form holds the state, there is no client JS, and collapsed borders leave exactly one pixel between segments.

  • buttongroup
  • segmented
  • radio
  • filter

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-001?lang=en

Show
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-001" (Segmented Filter) 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-001.json

Registry item: https://vibeui.ru/r/buttongroup-001.json
Installs to: components/vibeui/buttongroup-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
A segmented control built on real radio inputs: the form holds the state, there is no client JS, and collapsed borders leave exactly one pixel between segments.

A segmented control on radios: no client state, collapsed borders, keyboard behaviour handed over to the browser. Zero dependencies, one file, a server component.

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

<Buttongroup001
  options={["All", "Active", "Archived"]}
  defaultValue="Active"
  label="Show"
  name="status"
/>

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-001-* palette — do not swap it for your theme tokens
- radios inside a fieldset instead of buttons: arrows, Home/End and "2 of 3 selected" would otherwise be hand-written
- the z-index on the selected and focused segment: with collapsed borders a neighbour clips both the border and the focus ring
- rounded corners only on the first and last segment — otherwise the group falls apart into separate buttons
- the legend hidden with clip-path: display:none strips the fieldset of its accessible name
- the dot on the selected segment: a state carried by colour alone is unreadable to colour-blind users

## 6. You may change
- the set of segments through options
- the initially selected segment through defaultValue
- the accessible group name through label
- the form field name through name and the accent through accent

## 7. Rules
- More than five segments turn the control into a horizontal scroll — use a list or a select instead.
- All inputs share one name: two groups with the same name on a page cancel each other out.
- The component is uncontrolled: the value lives in the form and is read via FormData or a ref.
- 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-001.json
https://vibeui.ru/r/buttongroup-001.json

Компонент самодостаточен: один файл, без зависимостей, собственная палитра в переменных --vibeui-buttongroup-001-*. Серверный: хуков и обработчиков нет вовсе. Выбор держат radio внутри fieldset, поэтому стрелки, Home/End и объявление «выбрано 2 из 3» приходят от браузера. Input растянут на весь сегмент и прозрачен, вид рисует label. Границы схлопнуты через margin-inline-start:-1px, скругления заданы логическими свойствами только первому и последнему сегменту. Выбранный сегмент поднят z-index:1, сфокусированный — z-index:2, иначе сосед срезает акцентную рамку и обводку фокуса. Legend спрятан clip-path, а не display:none: иначе fieldset теряет доступное имя.

Component source

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

import type { ComponentPropsWithoutRef, CSSProperties } from "react"

export type Buttongroup001Props = Omit<
  ComponentPropsWithoutRef<"fieldset">,
  "children"
> & {
  options?: string[]
  defaultValue?: string
  label?: string
  name?: string
  accent?: string
}

// Идея компонента: сегментированный переключатель без единой строки JS.
// Состояние держат настоящие radio внутри fieldset: стрелки, Home/End и
// объявление «выбрано 2 из 3» приходят от браузера, а не от обработчиков.
// Границы сегментов схлопнуты отрицательным margin, поэтому между ними
// ровно один пиксель, а не два; выбранный и сфокусированный сегменты
// поднимаются z-index, иначе сосед срезает им рамку и обводку фокуса.
const STYLES = `
:where([data-vibeui-block="buttongroup-001"]){
--vibeui-buttongroup-001-surface:oklch(1 0 0);
--vibeui-buttongroup-001-fg:oklch(0.26 0.016 265);
--vibeui-buttongroup-001-muted:oklch(0.54 0.014 265);
--vibeui-buttongroup-001-border:oklch(0.88 0.008 265);
--vibeui-buttongroup-001-on:oklch(0.96 0.035 265);
--vibeui-buttongroup-001-on-fg:oklch(0.44 0.16 265);
--vibeui-buttongroup-001-accent:oklch(0.55 0.17 265);
--vibeui-buttongroup-001-radius:0.625rem;
--vibeui-buttongroup-001-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}
[data-vibeui-block="buttongroup-001"]{
display:inline-block;box-sizing:border-box;min-inline-size:0;
margin:0;padding:0;border:0;
font-family:var(--vibeui-buttongroup-001-font);
}
[data-vibeui-block="buttongroup-001"] *{box-sizing:border-box}
/* Подпись группы нужна скринридеру, но не витрине: убираем её из потока
   через clip-path, а не display:none — иначе fieldset теряет имя. */
[data-vibeui-block="buttongroup-001"] legend{
position:absolute;width:1px;height:1px;padding:0;margin:-1px;
overflow:hidden;clip-path:inset(50%);white-space:nowrap;
}
[data-vibeui-block="buttongroup-001"] [data-part="track"]{display:flex;isolation:isolate}
[data-vibeui-block="buttongroup-001"] [data-part="segment"]{
position:relative;z-index:0;
display:inline-flex;align-items:center;justify-content:center;gap:0.4375rem;
height:2.25rem;padding:0 0.875rem;margin-inline-start:-1px;
border:1px solid var(--vibeui-buttongroup-001-border);
background:var(--vibeui-buttongroup-001-surface);
color:var(--vibeui-buttongroup-001-muted);
font-size:0.8125rem;font-weight:600;line-height:1;white-space:nowrap;cursor:pointer;
transition:background-color .16s ease,color .16s ease,border-color .16s ease;
}
[data-vibeui-block="buttongroup-001"] [data-part="segment"]:first-child{
margin-inline-start:0;
border-start-start-radius:var(--vibeui-buttongroup-001-radius);
border-end-start-radius:var(--vibeui-buttongroup-001-radius);
}
[data-vibeui-block="buttongroup-001"] [data-part="segment"]:last-child{
border-start-end-radius:var(--vibeui-buttongroup-001-radius);
border-end-end-radius:var(--vibeui-buttongroup-001-radius);
}
[data-vibeui-block="buttongroup-001"] [data-part="segment"]:hover{color:var(--vibeui-buttongroup-001-fg)}
[data-vibeui-block="buttongroup-001"] input{
position:absolute;inset:0;width:100%;height:100%;margin:0;
opacity:0;cursor:pointer;
}
/* Выбранный сегмент поднят над соседями: иначе его акцентная рамка
   наполовину скрыта под рамкой следующего сегмента. */
[data-vibeui-block="buttongroup-001"] [data-part="segment"]:has(input:checked){
z-index:1;
background:var(--vibeui-buttongroup-001-on);
border-color:var(--vibeui-buttongroup-001-accent);
color:var(--vibeui-buttongroup-001-on-fg);
}
/* Обводка фокуса выходит за границу сегмента, поэтому z-index здесь ещё
   выше: сосед не должен её срезать. */
[data-vibeui-block="buttongroup-001"] [data-part="segment"]:has(input:focus-visible){
z-index:2;
outline:2px solid var(--vibeui-buttongroup-001-accent);outline-offset:1px;
}
/* Состояние помечено не только цветом: у выбранного появляется точка. */
[data-vibeui-block="buttongroup-001"] [data-part="mark"]{
width:0.375rem;height:0.375rem;border-radius:9999px;
background:currentColor;opacity:0;
transition:opacity .16s ease;
}
[data-vibeui-block="buttongroup-001"] [data-part="segment"]:has(input:checked) [data-part="mark"]{opacity:1}
@media (prefers-reduced-motion:reduce){[data-vibeui-block="buttongroup-001"] *{animation:none!important;transition:none!important}}
`

const DEFAULT_OPTIONS = ["Все", "Активные", "Архив"]

/**
 * Сегментированный переключатель на настоящих radio, без клиентского JS.
 * Один файл, ноль зависимостей, собственная палитра.
 */
export function Buttongroup001({
  options = DEFAULT_OPTIONS,
  defaultValue = "Активные",
  label = "Показывать",
  name = "buttongroup-001",
  accent,
  className,
  style,
  ...props
}: Buttongroup001Props) {
  const palette = {
    ...(accent ? { "--vibeui-buttongroup-001-accent": accent } : null),
    ...style,
  } as CSSProperties

  return (
    <>
      <style href="vibeui-buttongroup-001" precedence="medium">
        {STYLES}
      </style>
      <fieldset
        {...props}
        data-vibeui-block="buttongroup-001"
        className={className}
        style={palette}
      >
        <legend>{label}</legend>
        <div data-part="track">
          {options.map((option) => (
            <label key={option} data-part="segment">
              <input
                type="radio"
                name={name}
                value={option}
                defaultChecked={option === defaultValue}
              />
              <span data-part="mark" aria-hidden="true" />
              {option}
            </label>
          ))}
        </div>
      </fieldset>
    </>
  )
}