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
Use it with AI
- 1. Copy the link.
- 2. Write to your agent in your own words and drop the link into the sentence.
- 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.jsonhttps://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>
</>
)
}