Toggle Group

Required Group

A group with a required minimum: the last pressed button carries a lock and aria-disabled, and answers a press with an explanation rather than silence.

  • togglegroup
  • required
  • columns
  • validation

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/togglegroup-008?lang=en

Table columns

КлиентСтатусСумма
ООО «Сфера»Оплачен48 200 ₽

Показано колонок: 3 из 4.

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 "togglegroup-008" (Required Group) 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/togglegroup-008.json

Registry item: https://vibeui.ru/r/togglegroup-008.json
Installs to: components/vibeui/togglegroup-008.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 group with a required minimum: the last pressed button carries a lock and aria-disabled, and answers a press with an explanation rather than silence.

A toggle group with a required minimum: the last pressed button does not switch off and explains the refusal, and the table below is built from the selection. One file, zero dependencies.

## 3. How to use it
import { Togglegroup008 } from "@/components/vibeui/togglegroup-008"

<Togglegroup008
  label="Table columns"
  minMessage="At least one column must stay visible."
  defaultValue={["name", "status", "sum"]}
/>

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-togglegroup-008-* palette — do not swap it for your theme tokens
- aria-disabled only on the last pressed button: the others work and must not be dimmed
- the refusal spelled out in text — without it the press looks like a bug
- the lock as a visible cue: the nudge on refusal is switched off by prefers-reduced-motion
- the table assembled from the selected columns: it shows what the choice means
- the panel's own light surface: dark text must bring its own background

## 6. You may change
- the group name through label
- the refusal copy through minMessage
- the initial set of columns through defaultValue
- the pressed colour through accent

## 7. Rules
- The minimum is hard-coded as "at least one": another threshold means editing the condition.
- aria-disabled does not block the click — the handler refuses it, and that is correct.
- The message lives in a role="status" line and is announced on every refusal.
- 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/togglegroup-008.json
https://vibeui.ru/r/togglegroup-008.json

Один файл, ноль зависимостей, палитра --vibeui-togglegroup-008-*. Клиентский: useState на выбор и на последнюю заблокированную кнопку. Правило «хотя бы одно» не прячется: кнопка остаётся нажатой, получает aria-disabled и замок, а при попытке снять — короткое смещение и объяснение в строке. Заранее гасить остальные было бы враньём, они рабочие. Ниже стоит мини-таблица, которая рисуется из выбранных колонок, поэтому смысл выбора виден сразу. Это togglegroup, а не buttongroup: кнопки объявляют состояние видимости колонок.

Component source

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

"use client"

import { useState } from "react"
import type { ComponentPropsWithoutRef, CSSProperties } from "react"

export type Togglegroup008Props = Omit<
  ComponentPropsWithoutRef<"section">,
  "children" | "onChange"
> & {
  label?: string
  minMessage?: string
  defaultValue?: string[]
  onChange?: (value: string[]) => void
  accent?: string
}

// Идея компонента: группа с обязательным минимумом. Последняя нажатая кнопка
// не гаснет молча: она помечается aria-disabled и на нажатие отвечает
// объяснением. Убирать её из группы или отключать заранее нельзя — правило
// становится понятным ровно в тот момент, когда в него упираются.
const STYLES = `
:where([data-vibeui-block="togglegroup-008"]){
--vibeui-togglegroup-008-bg:oklch(1 0 0);
--vibeui-togglegroup-008-fg:oklch(0.22 0.014 265);
--vibeui-togglegroup-008-muted:oklch(0.55 0.014 265);
--vibeui-togglegroup-008-border:oklch(0.9 0.006 265);
--vibeui-togglegroup-008-surface:oklch(0.97 0.004 265);
--vibeui-togglegroup-008-accent:oklch(0.55 0.16 240);
--vibeui-togglegroup-008-warn:oklch(0.58 0.17 30);
--vibeui-togglegroup-008-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}
[data-vibeui-block="togglegroup-008"]{
box-sizing:border-box;display:flex;flex-direction:column;gap:0.625rem;
width:100%;max-width:24rem;padding:0.875rem;
border:1px solid var(--vibeui-togglegroup-008-border);border-radius:0.875rem;
background:var(--vibeui-togglegroup-008-bg);color:var(--vibeui-togglegroup-008-fg);
font-family:var(--vibeui-togglegroup-008-font);
}
[data-vibeui-block="togglegroup-008"] *{box-sizing:border-box}
[data-vibeui-block="togglegroup-008"] h3{margin:0;font-size:0.875rem;font-weight:650}
[data-vibeui-block="togglegroup-008"] [data-part="group"]{display:flex;flex-wrap:wrap;gap:0.375rem}
[data-vibeui-block="togglegroup-008"] button{
appearance:none;cursor:pointer;font:inherit;
display:inline-flex;align-items:center;gap:0.375rem;
height:2rem;padding:0 0.6875rem;
border:1px solid var(--vibeui-togglegroup-008-border);border-radius:9999px;
background:var(--vibeui-togglegroup-008-bg);color:var(--vibeui-togglegroup-008-muted);
font-size:0.8125rem;font-weight:600;line-height:1;
transition:background-color .15s ease,color .15s ease,border-color .15s ease;
}
[data-vibeui-block="togglegroup-008"] button:focus-visible{
outline:2px solid var(--vibeui-togglegroup-008-accent);outline-offset:2px;
}
[data-vibeui-block="togglegroup-008"] button[aria-pressed="true"]{
background:color-mix(in oklab,var(--vibeui-togglegroup-008-accent) 12%,white);
border-color:var(--vibeui-togglegroup-008-accent);
color:var(--vibeui-togglegroup-008-accent);
}
/* Единственная оставшаяся кнопка помечена замком и aria-disabled: она
   нажата и снять её нельзя — это состояние, а не запрет на весь элемент. */
[data-vibeui-block="togglegroup-008"] button[aria-disabled="true"]{cursor:not-allowed}
[data-vibeui-block="togglegroup-008"] button svg{width:0.75rem;height:0.75rem}
[data-vibeui-block="togglegroup-008"] button[data-blocked="true"]{
border-color:var(--vibeui-togglegroup-008-warn);color:var(--vibeui-togglegroup-008-warn);
animation:vibeui-togglegroup-008-nudge .22s ease;
}
@keyframes vibeui-togglegroup-008-nudge{
0%,100%{transform:translateX(0)}
30%{transform:translateX(-0.1875rem)}
70%{transform:translateX(0.1875rem)}
}
[data-vibeui-block="togglegroup-008"] table{
width:100%;border-collapse:collapse;font-size:0.75rem;
border:1px solid var(--vibeui-togglegroup-008-border);border-radius:0.5rem;overflow:hidden;
}
[data-vibeui-block="togglegroup-008"] th,[data-vibeui-block="togglegroup-008"] td{
padding:0.375rem 0.5rem;text-align:left;border-bottom:1px solid var(--vibeui-togglegroup-008-border);
}
[data-vibeui-block="togglegroup-008"] th{background:var(--vibeui-togglegroup-008-surface);font-weight:650}
[data-vibeui-block="togglegroup-008"] tr:last-child td{border-bottom:0}
[data-vibeui-block="togglegroup-008"] [data-part="note"]{
margin:0;font-size:0.75rem;line-height:1.4;color:var(--vibeui-togglegroup-008-muted);
}
[data-vibeui-block="togglegroup-008"] [data-part="note"][data-warn="true"]{color:var(--vibeui-togglegroup-008-warn)}
@media (prefers-reduced-motion:reduce){[data-vibeui-block="togglegroup-008"] *{animation:none!important;transition:none!important}}
`

const COLUMNS = [
  { id: "name", label: "Клиент", cell: "ООО «Сфера»" },
  { id: "status", label: "Статус", cell: "Оплачен" },
  { id: "date", label: "Дата", cell: "14.03" },
  { id: "sum", label: "Сумма", cell: "48 200 ₽" },
]

/**
 * Группа тумблеров с обязательным минимумом: последняя нажатая кнопка
 * помечена aria-disabled и объясняет отказ. Один файл, ноль зависимостей.
 */
export function Togglegroup008({
  label = "Колонки таблицы",
  minMessage = "Хотя бы одна колонка должна остаться видимой.",
  defaultValue = ["name", "status", "sum"],
  onChange,
  accent,
  className,
  style,
  ...props
}: Togglegroup008Props) {
  const [value, setValue] = useState<string[]>(defaultValue)
  const [blocked, setBlocked] = useState("")

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

  const locked = (id: string) => value.length === 1 && value[0] === id

  const toggle = (id: string) => {
    if (locked(id)) {
      setBlocked(id)
      return
    }

    const next = value.includes(id)
      ? value.filter((item) => item !== id)
      : [...value, id]

    setBlocked("")
    setValue(next)
    onChange?.(next)
  }

  const shown = COLUMNS.filter((column) => value.includes(column.id))

  return (
    <>
      <style href="vibeui-togglegroup-008" precedence="medium">
        {STYLES}
      </style>
      <section
        {...props}
        data-vibeui-block="togglegroup-008"
        className={className}
        style={palette}
      >
        <h3>{label}</h3>
        <div data-part="group" role="group" aria-label={label}>
          {COLUMNS.map((column) => (
            <button
              key={column.id}
              type="button"
              aria-pressed={value.includes(column.id)}
              aria-disabled={locked(column.id)}
              data-blocked={blocked === column.id}
              onClick={() => toggle(column.id)}
            >
              {locked(column.id) ? (
                <svg viewBox="0 0 12 12" fill="none" aria-hidden="true">
                  <path
                    d="M3.2 5.2V4a2.8 2.8 0 0 1 5.6 0v1.2M2.6 5.2h6.8v5H2.6z"
                    stroke="currentColor"
                    strokeWidth="1.2"
                    strokeLinejoin="round"
                  />
                </svg>
              ) : null}
              {column.label}
            </button>
          ))}
        </div>
        <table>
          <thead>
            <tr>
              {shown.map((column) => (
                <th key={column.id} scope="col">
                  {column.label}
                </th>
              ))}
            </tr>
          </thead>
          <tbody>
            <tr>
              {shown.map((column) => (
                <td key={column.id}>{column.cell}</td>
              ))}
            </tr>
          </tbody>
        </table>
        <p data-part="note" data-warn={blocked !== ""} role="status">
          {blocked ? minMessage : `Показано колонок: ${value.length} из 4.`}
        </p>
      </section>
    </>
  )
}