Buttons

Tri-State Select

A toggle with a third state: besides all and nothing a list can be 3 of 8, and that is announced through aria-pressed="mixed" instead of being hidden in a colour.

  • button
  • toggle
  • mixed
  • selection

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/button-024?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 "button-024" (Tri-State Select) 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/button-024.json

Registry item: https://vibeui.ru/r/button-024.json
Installs to: components/vibeui/button-024.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 toggle with a third state: besides all and nothing a list can be 3 of 8, and that is announced through aria-pressed="mixed" instead of being hidden in a colour.

A select-all button for a list that is already partly checked. The state comes from the counter: none, all, or partial — and the last one is announced as aria-pressed="mixed", something a boolean toggle cannot express. The box then holds a dash rather than a check, with 3 / 8 beside it.

## 3. How to use it
import { Button024 } from "@/components/vibeui/button-024"

<Button024 total={8} defaultSelected={3} onSelectedChange={setChecked} />

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 three aria-pressed states: false, true and mixed — the last one is the reason this component exists
- the dash instead of a check in the partial state: a partial selection has to differ from a full one without relying on colour
- the visible selected-of-total counter next to the label — it explains where the partial state comes from
- the transition rule: from partial and empty a click selects everything, from full it clears everything
- tabular-nums on the counter, otherwise the button twitches as the number changes
- the light plate with a border: this button belongs on a toolbar above a list, not in a dark hero

## 6. You may change
- the caption through the label prop
- the list size through total and the starting number of checked rows through defaultSelected
- the reaction to toggling through the onSelectedChange prop
- the accent colour through the accent prop
- outer spacing through className

## 7. Rules
- The component holds a number, not the list: the real row checkboxes are switched by your app from onSelectedChange.
- Do not swap aria-pressed="mixed" for a checkbox with indeterminate — that is a different role and different markup.
- Do not leave defaultSelected above total: the value is clamped, but the two should not contradict each other.
- 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/button-024.json
https://vibeui.ru/r/button-024.json

Компонент самодостаточен: один файл, без зависимостей, собственная палитра в локальных переменных --vibeui-button-024-*. Клиентский: число выбранного хранится внутри и отдаётся наружу через onSelectedChange. Значение aria-pressed считается из счётчика: 0 — false, total — true, между ними — mixed. Значок в квадрате переключается между галочкой и тире, счётчик набран tabular-nums.

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 Button024Props = Omit<
  ComponentPropsWithoutRef<"button">,
  "children" | "onClick"
> & {
  label?: string
  /** Сколько элементов всего. */
  total?: number
  /** Сколько выбрано на старте: между нулём и total получится «частично». */
  defaultSelected?: number
  onSelectedChange?: (selected: number) => void
  accent?: string
}

// Идея компонента: переключатель с третьим состоянием. Кроме «всё выбрано» и
// «ничего не выбрано» у списка есть промежуточное «выбрано 3 из 8», и его
// нельзя показать булевым aria-pressed. Здесь состояние объявлено значением
// mixed, а значок в квадрате меняется с галочки на тире — состояние читается
// и скринридером, и глазом, без опоры на цвет.
const STYLES = `
:where([data-vibeui-block="button-024"]){
--vibeui-button-024-bg:oklch(1 0 0);
--vibeui-button-024-fg:oklch(0.26 0.016 265);
--vibeui-button-024-muted:oklch(0.55 0.014 265);
--vibeui-button-024-border:oklch(0.9 0.006 265);
--vibeui-button-024-accent:oklch(0.55 0.17 265);
--vibeui-button-024-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}
[data-vibeui-block="button-024"]{
appearance:none;cursor:pointer;
display:inline-flex;align-items:center;gap:0.625rem;
height:2.375rem;padding:0 0.875rem;box-sizing:border-box;
border:1px solid var(--vibeui-button-024-border);border-radius:0.5rem;
background:var(--vibeui-button-024-bg);color:var(--vibeui-button-024-fg);
font-family:var(--vibeui-button-024-font);font-size:0.875rem;font-weight:550;line-height:1;
transition:border-color .16s ease,background-color .16s ease;
}
[data-vibeui-block="button-024"]:hover{border-color:var(--vibeui-button-024-accent)}
[data-vibeui-block="button-024"]:focus-visible{outline:2px solid var(--vibeui-button-024-accent);outline-offset:2px}
[data-vibeui-block="button-024"] [data-part="box"]{
flex:none;position:relative;width:1.0625rem;height:1.0625rem;box-sizing:border-box;
border:1.5px solid var(--vibeui-button-024-border);border-radius:0.3125rem;
transition:background-color .16s ease,border-color .16s ease;
}
[data-vibeui-block="button-024"][aria-pressed="true"] [data-part="box"],
[data-vibeui-block="button-024"][aria-pressed="mixed"] [data-part="box"]{
background:var(--vibeui-button-024-accent);border-color:var(--vibeui-button-024-accent);
}
/* Галочка и тире — второй канал состояния помимо заливки. */
[data-vibeui-block="button-024"] [data-part="tick"]{
position:absolute;left:0.3125rem;top:0.0625rem;width:0.25rem;height:0.5rem;
border-right:2px solid oklch(1 0 0);border-bottom:2px solid oklch(1 0 0);
transform:rotate(45deg);
}
[data-vibeui-block="button-024"] [data-part="dash"]{
position:absolute;left:0.1875rem;top:0.4375rem;width:0.5rem;height:2px;
border-radius:1px;background:oklch(1 0 0);
}
[data-vibeui-block="button-024"] [data-part="count"]{
color:var(--vibeui-button-024-muted);font-variant-numeric:tabular-nums;font-weight:500;
}
@media (prefers-reduced-motion:reduce){[data-vibeui-block="button-024"] *{animation:none!important;transition:none!important}}
`

/**
 * Кнопка «выбрать всё» с третьим состоянием через aria-pressed="mixed".
 * Один файл, ноль зависимостей, собственная палитра.
 */
export function Button024({
  label = "Выбрать все",
  total = 8,
  defaultSelected = 3,
  onSelectedChange,
  accent,
  type = "button",
  className,
  style,
  ...props
}: Button024Props) {
  const [selected, setSelected] = useState(
    Math.min(Math.max(defaultSelected, 0), total),
  )

  const pressed =
    selected === 0 ? "false" : selected === total ? "true" : "mixed"

  const toggle = () => {
    const next = selected === total ? 0 : total
    setSelected(next)
    onSelectedChange?.(next)
  }

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

  return (
    <>
      <style href="vibeui-button-024" precedence="medium">
        {STYLES}
      </style>
      <button
        {...props}
        type={type}
        data-vibeui-block="button-024"
        className={className}
        style={palette}
        aria-pressed={pressed}
        onClick={toggle}
      >
        <span data-part="box" aria-hidden="true">
          {pressed === "true" ? <span data-part="tick" /> : null}
          {pressed === "mixed" ? <span data-part="dash" /> : null}
        </span>
        {label}
        <span data-part="count">
          {selected} / {total}
        </span>
      </button>
    </>
  )
}