Button Group

Column Chooser

A visible-columns chooser in a details panel with a counter in its header, where the last remaining column cannot be unchecked — a table without columns is a dead end.

  • buttongroup
  • columns
  • details
  • checkbox

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

Columns4 из 6
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-049" (Column Chooser) 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-049.json

Registry item: https://vibeui.ru/r/buttongroup-049.json
Installs to: components/vibeui/buttongroup-049.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 visible-columns chooser in a details panel with a counter in its header, where the last remaining column cannot be unchecked — a table without columns is a dead end.

A table column chooser in a disclosure panel with a counter and a guard on the last column. Zero dependencies, one file, a client component.

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

<Buttongroup049
  columns={["Name", "Status", "Due"]}
  hiddenColumns={["Due"]}
  triggerLabel="Columns"
  onChange={(visible) => setColumns(visible)}
/>

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-049-* palette — do not swap it for your theme tokens
- details/summary instead of a hand-rolled menu: disclosure and keyboard come from the browser
- the ban on unchecking the last column — a table without columns is a dead end, not a result
- the title explanation on the blocked checkbox: a silent control reads as broken
- the counter in the header: a collapsed panel otherwise never says how much is hidden
- real checkboxes instead of drawn ones — state and keyboard come for free

## 6. You may change
- the full column list through columns
- the initially hidden columns through hiddenColumns
- the trigger caption through triggerLabel and the panel name through label
- the change callback through onChange and the checkbox colour through accent

## 7. Rules
- details does not close on an outside click: a real menu needs a popover or a handler.
- The panel opens down and to the left: near the right edge of the screen it has to be flipped.
- The component does not rebuild the table: applying the list 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-049.json
https://vibeui.ru/r/buttongroup-049.json

Компонент самодостаточен: один файл, без зависимостей, собственная палитра в переменных --vibeui-buttongroup-049-*. Клиентский: useState хранит список видимых колонок, счётчик «4 из 6» в шапке пересчитывается из состояния. Раскрытие держит details/summary: состояние, клавиатура и объявление «свёрнуто/развёрнуто» приходят от браузера, и самодельного меню не нужно. Последний оставшийся флажок получает disabled и объяснение через title: молчащий элемент читается как поломка, а таблица без колонок — тупик, из которого мышью не выбраться. Панель позиционирована абсолютно относительно корня и лежит выше содержимого через z-index:5. Флажки настоящие, их цвет задан accent-color.

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 Buttongroup049Props = Omit<
  ComponentPropsWithoutRef<"div">,
  "children" | "onChange"
> & {
  columns?: string[]
  hiddenColumns?: string[]
  triggerLabel?: string
  label?: string
  onChange?: (visible: string[]) => void
  accent?: string
}

// Идея компонента: выбор видимых колонок таблицы. Раскрытие держит
// details/summary — состояние, клавиатура и объявление «свёрнуто» приходят
// от браузера, а счётчик в шапке обновляется из состояния React. Последняя
// оставшаяся колонка блокируется: таблица без колонок — не результат,
// который пользователь хотел, а тупик, из которого он не выберется мышью.
// Заблокированный флажок получает disabled и объяснение через title, а не
// просто перестаёт реагировать: молчащий элемент читается как поломка.
const STYLES = `
:where([data-vibeui-block="buttongroup-049"]){
--vibeui-buttongroup-049-surface:oklch(1 0 0);
--vibeui-buttongroup-049-fg:oklch(0.25 0.016 265);
--vibeui-buttongroup-049-muted:oklch(0.56 0.014 265);
--vibeui-buttongroup-049-border:oklch(0.89 0.008 265);
--vibeui-buttongroup-049-accent:oklch(0.5 0.16 265);
--vibeui-buttongroup-049-radius:0.625rem;
--vibeui-buttongroup-049-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}
[data-vibeui-block="buttongroup-049"]{
box-sizing:border-box;display:inline-block;position:relative;
font-family:var(--vibeui-buttongroup-049-font);
}
[data-vibeui-block="buttongroup-049"] *{box-sizing:border-box}
[data-vibeui-block="buttongroup-049"] summary{
list-style:none;cursor:pointer;
display:inline-flex;align-items:center;gap:0.4375rem;
height:2.25rem;padding:0 0.75rem;
border:1px solid var(--vibeui-buttongroup-049-border);
border-radius:var(--vibeui-buttongroup-049-radius);
background:var(--vibeui-buttongroup-049-surface);
color:var(--vibeui-buttongroup-049-fg);
font-size:0.8125rem;font-weight:600;line-height:1;white-space:nowrap;
transition:background-color .16s ease;
}
[data-vibeui-block="buttongroup-049"] summary::marker{content:""}
[data-vibeui-block="buttongroup-049"] summary::-webkit-details-marker{display:none}
[data-vibeui-block="buttongroup-049"] summary:hover{background:oklch(0.97 0.004 265)}
[data-vibeui-block="buttongroup-049"] summary:focus-visible{
outline:2px solid var(--vibeui-buttongroup-049-accent);outline-offset:2px;
}
[data-vibeui-block="buttongroup-049"] [data-part="count"]{
color:var(--vibeui-buttongroup-049-muted);font-variant-numeric:tabular-nums;
}
[data-vibeui-block="buttongroup-049"] summary svg{
width:0.875rem;height:0.875rem;
stroke:currentColor;fill:none;stroke-width:2.2;stroke-linecap:round;stroke-linejoin:round;
transition:rotate .18s ease;
}
[data-vibeui-block="buttongroup-049"] details[open] summary svg{rotate:180deg}
[data-vibeui-block="buttongroup-049"] [data-part="panel"]{
position:absolute;z-index:5;top:calc(100% + 0.375rem);left:0;
min-width:13rem;padding:0.375rem;
border:1px solid var(--vibeui-buttongroup-049-border);
border-radius:0.75rem;
background:var(--vibeui-buttongroup-049-surface);
box-shadow:0 8px 24px oklch(0.2 0.03 265 / 16%);
}
[data-vibeui-block="buttongroup-049"] [data-part="row"]{
display:flex;align-items:center;gap:0.5rem;
padding:0.4375rem 0.5rem;border-radius:0.5rem;
color:var(--vibeui-buttongroup-049-fg);
font-size:0.8125rem;line-height:1.2;cursor:pointer;
transition:background-color .14s ease;
}
[data-vibeui-block="buttongroup-049"] [data-part="row"]:hover{background:oklch(0.97 0.004 265)}
[data-vibeui-block="buttongroup-049"] [data-part="row"]:has(input:disabled){
color:var(--vibeui-buttongroup-049-muted);cursor:not-allowed;
}
[data-vibeui-block="buttongroup-049"] [data-part="row"]:has(input:focus-visible){
outline:2px solid var(--vibeui-buttongroup-049-accent);outline-offset:-2px;
}
[data-vibeui-block="buttongroup-049"] input{
width:1rem;height:1rem;margin:0;flex:none;accent-color:var(--vibeui-buttongroup-049-accent);
}
@media (prefers-reduced-motion:reduce){[data-vibeui-block="buttongroup-049"] *{animation:none!important;transition:none!important}}
`

const DEFAULT_COLUMNS = [
  "Название",
  "Ответственный",
  "Статус",
  "Срок",
  "Приоритет",
  "Обновлено",
]

/**
 * Выбор видимых колонок в details, где последнюю колонку снять нельзя.
 * Один файл, ноль зависимостей, собственная палитра.
 */
export function Buttongroup049({
  columns = DEFAULT_COLUMNS,
  hiddenColumns = ["Приоритет", "Обновлено"],
  triggerLabel = "Колонки",
  label = "Видимые колонки таблицы",
  onChange,
  accent,
  className,
  style,
  ...props
}: Buttongroup049Props) {
  const [visible, setVisible] = useState<string[]>(
    columns.filter((column) => !hiddenColumns.includes(column)),
  )

  const toggle = (column: string) => {
    const next = visible.includes(column)
      ? visible.filter((value) => value !== column)
      : [...visible, column]

    setVisible(next)
    onChange?.(next)
  }

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

  return (
    <>
      <style href="vibeui-buttongroup-049" precedence="medium">
        {STYLES}
      </style>
      <div
        {...props}
        data-vibeui-block="buttongroup-049"
        className={className}
        style={palette}
      >
        <details>
          <summary>
            {triggerLabel}
            <span data-part="count">
              {visible.length} из {columns.length}
            </span>
            <svg viewBox="0 0 24 24" aria-hidden="true">
              <path d="m6 9 6 6 6-6" />
            </svg>
          </summary>
          <div data-part="panel" role="group" aria-label={label}>
            {columns.map((column) => {
              const checked = visible.includes(column)
              const last = checked && visible.length === 1

              return (
                <label
                  key={column}
                  data-part="row"
                  title={
                    last ? "Хотя бы одна колонка должна остаться" : undefined
                  }
                >
                  <input
                    type="checkbox"
                    checked={checked}
                    disabled={last}
                    onChange={() => toggle(column)}
                  />
                  <span>{column}</span>
                </label>
              )
            })}
          </div>
        </details>
      </div>
    </>
  )
}