Inputs
Stepwise Picker
Каскад для узкого экрана: на панели всегда один уровень, шапка со стрелкой «назад», а направление съезда списка подсказывает, куда шагнул пользователь.
- cascader
- mobile
- stepper
- drill-down
Превью
Использовать с AI
- 1. Скопируйте ссылку.
- 2. Напишите агенту своими словами и вставьте её в предложение.
- 3. Агент откроет ссылку и поставит компонент из registry.
размести это в шапке: https://vibeui.ru/c/cascader-004
Показать полную инструкцию
То, что лежит по ссылке в развёрнутом виде. Нужна, если агент не может открыть ссылку — тогда вставьте этот текст целиком.
# Установка и размещение «cascader-004» (Stepwise Picker) из VibeUI
## 1. Сначала установка — не пропускай, не пересоздавай
Выполни эту команду до того, как писать код:
npx shadcn@latest add https://vibeui.ru/r/cascader-004.json
Registry item: https://vibeui.ru/r/cascader-004.json
Ставится в: components/vibeui/cascader-004.tsx (точный путь следует алиасам components.json этого проекта).
npm-зависимости: нет.
Registry-зависимости: нет.
Ставь из реестра. Не пересоздавай по описанию, не подменяй похожим
компонентом из другой библиотеки и не переписывай под стиль проекта.
## 2. Что это
Каскад для узкого экрана: на панели всегда один уровень, шапка со стрелкой «назад», а направление съезда списка подсказывает, куда шагнул пользователь.
Пошаговый каскад: один уровень на экране, стрелка «назад» в шапке и подпись текущего шага. Один файл, ноль зависимостей, клиентское состояние — путь и направление перехода.
## 3. Как использовать
import { Cascader004 } from "@/components/vibeui/cascader-004"
<Cascader004 heading="Куда доставить" accent="#0ea5a4" />
Полный список пропсов — в установленном файле.
## 4. Куда поставить
Это мелкий inline-компонент. Поставь его ровно туда, куда просил
пользователь, внутрь существующей разметки. Не создавай ради него
новую страницу, секцию или обёртку. Если на этом месте уже стоит похожий
контрол — замени его, а не добавляй второй.
Куда поставить: ___
(Эту строку заполняет пользователь. Если она осталась пустой — спроси,
а не угадывай.)
## 5. Сохранить как установлено
- локальную палитру --vibeui-cascader-004-* и не подменять её токенами вашей темы
- перемонтирование списка по key из пути: без него анимация шага не проигрывается заново
- разные keyframes для шага вперёд и назад — одинаковое движение не объясняет, куда попал пользователь
- отключённую, а не спрятанную кнопку «назад» на верхнем уровне: иначе шапка меняет высоту
- минимальную высоту списка — короткий уровень иначе схлопывает панель и она прыгает
- собственную светлую подложку карточки: на тёмной карточке каталога тёмный текст пропадает
## 6. Можно менять
- подпись сценария через heading
- акцентный цвет через accent
- уровни и подписи-приписки через tree
- ширину карточки через max-width на корне
## 7. Правила
- Пользователь видит один уровень: без строки пути в шапке он теряет контекст на третьем шаге.
- Анимация выключается через prefers-reduced-motion — проверяйте, что без неё шаг всё ещё понятен.
- Выбор листа не закрывает панель: закрытие принадлежит родительскому диалогу, а не каскаду.
- Не выносите CSS-переменные в globals.css: компонент обязан оставаться одним файлом.
## 8. Проверка
- рендерится без ошибок в консоли;
- выглядит так же, как превью на странице VibeUI, откуда взята ссылка;
- если нет — ты изменил что-то из раздела 5, верни как было.Для разработчика
npx shadcn@latest add https://vibeui.ru/r/cascader-004.jsonhttps://vibeui.ru/r/cascader-004.jsonКомпонент самодостаточен: один файл, без зависимостей, собственная палитра в локальных переменных --vibeui-cascader-004-*. Клиентский: путь, направление перехода и выбранный лист живут в useState. Список перемонтируется по key из пути, поэтому CSS-анимация проигрывается заново на каждом шаге, а имя keyframes переключается атрибутом data-direction — вперёд список приезжает справа, назад слева. Кнопка «назад» отключена на верхнем уровне: неактивная цель честнее исчезнувшей, потому что шапка не меняет высоту.
Исходник компонента
Тот же файл, который поставит агент. Нужен, если вы предпочитаете скопировать код руками.
"use client"
import { useState } from "react"
import type { CSSProperties } from "react"
export type Cascader004Node = {
label: string
note?: string
children?: Cascader004Node[]
}
export type Cascader004Props = {
heading?: string
tree?: Cascader004Node[]
accent?: string
className?: string
style?: CSSProperties
}
// Идея компонента: каскад для телефона — на экране всегда ровно один уровень.
// Колонки рядом на 360 px не помещаются, поэтому уровни идут шаг за шагом:
// шапка со стрелкой «назад» и названием текущего уровня, под ней крупные
// строки в один столбец. Панель едет влево при углублении и вправо при
// возврате — направление движения объясняет, куда пользователь попал.
const STYLES = `
:where([data-vibeui-block="cascader-004"]){
--vibeui-cascader-004-bg:oklch(1 0 0);
--vibeui-cascader-004-fg:oklch(0.22 0.014 250);
--vibeui-cascader-004-muted:oklch(0.55 0.012 250);
--vibeui-cascader-004-border:oklch(0.91 0.006 250);
--vibeui-cascader-004-accent:oklch(0.58 0.15 190);
--vibeui-cascader-004-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}
[data-vibeui-block="cascader-004"]{
display:flex;flex-direction:column;
width:100%;max-width:19rem;box-sizing:border-box;overflow:hidden;
background:var(--vibeui-cascader-004-bg);color:var(--vibeui-cascader-004-fg);
border:1px solid var(--vibeui-cascader-004-border);border-radius:1.125rem;
font-family:var(--vibeui-cascader-004-font);
box-shadow:0 20px 44px -34px oklch(0.2 0.03 250 / 65%);
}
[data-vibeui-block="cascader-004"] *{box-sizing:border-box}
[data-vibeui-block="cascader-004"] [data-part="bar"]{
display:flex;align-items:center;gap:0.5rem;
padding:0.625rem 0.75rem;border-bottom:1px solid var(--vibeui-cascader-004-border);
}
[data-vibeui-block="cascader-004"] [data-part="back"]{
flex:0 0 auto;display:grid;place-items:center;width:1.75rem;height:1.75rem;
border:1px solid var(--vibeui-cascader-004-border);border-radius:0.625rem;
background:var(--vibeui-cascader-004-bg);color:var(--vibeui-cascader-004-fg);
cursor:pointer;transition:border-color .14s ease,opacity .14s ease;
}
[data-vibeui-block="cascader-004"] [data-part="back"]:hover:not(:disabled){
border-color:var(--vibeui-cascader-004-accent);
}
[data-vibeui-block="cascader-004"] [data-part="back"]:disabled{opacity:.35;cursor:not-allowed}
[data-vibeui-block="cascader-004"] [data-part="back"]:focus-visible{
outline:2px solid var(--vibeui-cascader-004-accent);outline-offset:2px;
}
[data-vibeui-block="cascader-004"] [data-part="back"] i{
width:0.4375rem;height:0.4375rem;
border-left:1.5px solid currentColor;border-bottom:1.5px solid currentColor;
transform:rotate(45deg) translate(0.0625rem,-0.0625rem);
}
[data-vibeui-block="cascader-004"] [data-part="titles"]{
flex:1 1 auto;min-width:0;display:flex;flex-direction:column;
}
[data-vibeui-block="cascader-004"] [data-part="title"]{
margin:0;font-size:0.875rem;font-weight:650;letter-spacing:-0.01em;
overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
}
[data-vibeui-block="cascader-004"] [data-part="step"]{
margin:0;font-size:0.6875rem;color:var(--vibeui-cascader-004-muted);
}
[data-vibeui-block="cascader-004"] [data-part="viewport"]{overflow:hidden}
[data-vibeui-block="cascader-004"] [data-part="list"]{
display:flex;flex-direction:column;margin:0;padding:0.375rem;list-style:none;
min-height:9.5rem;
animation:vibeui-cascader-004-forward .2s ease both;
}
[data-vibeui-block="cascader-004"] [data-part="list"][data-direction="back"]{
animation-name:vibeui-cascader-004-back;
}
@keyframes vibeui-cascader-004-forward{
from{opacity:0;transform:translateX(1.25rem)}
to{opacity:1;transform:none}
}
@keyframes vibeui-cascader-004-back{
from{opacity:0;transform:translateX(-1.25rem)}
to{opacity:1;transform:none}
}
[data-vibeui-block="cascader-004"] [data-part="row"]{
display:flex;align-items:center;gap:0.625rem;width:100%;
padding:0.5625rem 0.625rem;border:0;border-radius:0.75rem;
background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer;
transition:background-color .14s ease;
}
[data-vibeui-block="cascader-004"] [data-part="row"]:hover{
background:color-mix(in oklab,var(--vibeui-cascader-004-accent) 10%,transparent);
}
[data-vibeui-block="cascader-004"] [data-part="row"]:focus-visible{
outline:2px solid var(--vibeui-cascader-004-accent);outline-offset:-2px;
}
[data-vibeui-block="cascader-004"] [data-part="row"][aria-current="true"] [data-part="name"]{
color:var(--vibeui-cascader-004-accent);font-weight:650;
}
[data-vibeui-block="cascader-004"] [data-part="texts"]{flex:1 1 auto;min-width:0}
[data-vibeui-block="cascader-004"] [data-part="name"]{
display:block;font-size:0.875rem;line-height:1.3;
overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
}
[data-vibeui-block="cascader-004"] [data-part="note"]{
display:block;font-size:0.6875rem;line-height:1.3;color:var(--vibeui-cascader-004-muted);
}
[data-vibeui-block="cascader-004"] [data-part="next"]{
flex:0 0 auto;width:0.375rem;height:0.375rem;
border-right:1.5px solid var(--vibeui-cascader-004-muted);
border-top:1.5px solid var(--vibeui-cascader-004-muted);
transform:rotate(45deg);
}
[data-vibeui-block="cascader-004"] [data-part="foot"]{
display:flex;align-items:center;gap:0.5rem;
padding:0.625rem 0.75rem;border-top:1px solid var(--vibeui-cascader-004-border);
background:color-mix(in oklab,var(--vibeui-cascader-004-accent) 6%,transparent);
}
[data-vibeui-block="cascader-004"] [data-part="crumbs"]{
margin:0;flex:1 1 auto;min-width:0;font-size:0.75rem;line-height:1.35;
color:var(--vibeui-cascader-004-muted);
}
[data-vibeui-block="cascader-004"] [data-part="crumbs"] b{
color:var(--vibeui-cascader-004-fg);font-weight:650;
}
@media (prefers-reduced-motion:reduce){
[data-vibeui-block="cascader-004"] *{animation:none!important;transition:none!important}
}
`
const DEFAULT_TREE: Cascader004Node[] = [
{
label: "Россия",
note: "85 регионов",
children: [
{
label: "Приморский край",
note: "12 городов",
children: [
{ label: "Владивосток", note: "600 тыс. жителей" },
{ label: "Находка", note: "140 тыс. жителей" },
{ label: "Уссурийск", note: "180 тыс. жителей" },
],
},
{
label: "Татарстан",
note: "22 города",
children: [
{ label: "Казань", note: "1,3 млн жителей" },
{ label: "Челны", note: "530 тыс. жителей" },
],
},
],
},
{
label: "Казахстан",
note: "17 областей",
children: [
{
label: "Алматинская область",
note: "9 городов",
children: [{ label: "Алматы" }, { label: "Талдыкорган" }],
},
{
label: "Акмолинская область",
note: "6 городов",
children: [{ label: "Астана" }, { label: "Кокшетау" }],
},
],
},
]
function nodesAt(tree: Cascader004Node[], path: string[]) {
let nodes = tree
for (const step of path) {
const found = nodes.find((node) => node.label === step)
if (!found?.children?.length) {
return nodes
}
nodes = found.children
}
return nodes
}
/**
* Каскадный выбор шаг за шагом: один уровень на экране и кнопка «назад».
* Один файл, ноль зависимостей, собственная палитра.
*/
export function Cascader004({
heading = "Куда доставить",
tree = DEFAULT_TREE,
accent,
className,
style,
}: Cascader004Props) {
const [path, setPath] = useState<string[]>(["Россия"])
const [direction, setDirection] = useState<"forward" | "back">("forward")
const [picked, setPicked] = useState<string[]>([])
const nodes = nodesAt(tree, path)
const title = path.length ? path[path.length - 1] : heading
const open = (node: Cascader004Node) => {
if (node.children?.length) {
setDirection("forward")
setPath([...path, node.label])
setPicked([])
return
}
setPicked([...path, node.label])
}
const goBack = () => {
setDirection("back")
setPath(path.slice(0, -1))
setPicked([])
}
const palette = {
...(accent ? { "--vibeui-cascader-004-accent": accent } : null),
...style,
} as CSSProperties
return (
<>
<style href="vibeui-cascader-004" precedence="medium">
{STYLES}
</style>
<div
data-vibeui-block="cascader-004"
className={className}
style={palette}
>
<header data-part="bar">
<button
data-part="back"
type="button"
onClick={goBack}
disabled={path.length === 0}
aria-label="Вернуться на уровень выше"
>
<i aria-hidden="true" />
</button>
<span data-part="titles">
<strong data-part="title">{title}</strong>
<small data-part="step">
Шаг {path.length + 1} · {heading}
</small>
</span>
</header>
<div data-part="viewport">
<ul key={path.join("/")} data-part="list" data-direction={direction}>
{nodes.map((node) => (
<li key={node.label}>
<button
data-part="row"
type="button"
aria-current={
picked[picked.length - 1] === node.label
? "true"
: undefined
}
onClick={() => open(node)}
>
<span data-part="texts">
<span data-part="name">{node.label}</span>
{node.note ? (
<span data-part="note">{node.note}</span>
) : null}
</span>
{node.children?.length ? (
<i data-part="next" aria-hidden="true" />
) : null}
</button>
</li>
))}
</ul>
</div>
<footer data-part="foot">
<p data-part="crumbs" aria-live="polite">
{picked.length ? (
<>
Выбрано: <b>{picked.join(" → ")}</b>
</>
) : (
<>
Сейчас:{" "}
<b>{path.length ? path.join(" → ") : "верхний уровень"}</b>
</>
)}
</p>
</footer>
</div>
</>
)
}