Inputs
Stepwise Picker
A cascader for a narrow screen: one level at a time, a back arrow in the header, and the slide direction telling the user which way they stepped.
- cascader
- mobile
- stepper
- drill-down
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/cascader-004?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 "cascader-004" (Stepwise Picker) 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/cascader-004.json
Registry item: https://vibeui.ru/r/cascader-004.json
Installs to: components/vibeui/cascader-004.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 cascader for a narrow screen: one level at a time, a back arrow in the header, and the slide direction telling the user which way they stepped.
A stepwise cascader: one level on screen, a back arrow in the header and a caption for the current step. One file, zero dependencies, the client state is the path and the step direction.
## 3. How to use it
import { Cascader004 } from "@/components/vibeui/cascader-004"
<Cascader004 heading="Where to deliver" accent="#0ea5a4" />
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-cascader-004-* palette — do not swap it for your theme tokens
- remounting the list by a key built from the path: without it the step animation never replays
- the two separate keyframes for forward and back — identical motion does not tell the user where they landed
- the disabled, not hidden, back button on the top level: otherwise the header changes height
- the minimum list height — a short level otherwise collapses the panel and it jumps
- the card's own light surface: dark text disappears on the dark catalogue card
## 6. You may change
- the scenario caption through heading
- the accent colour through accent
- the levels and their side notes through tree
- the card width through max-width on the root
## 7. Rules
- The user sees a single level: without the path line in the header they lose context by the third step.
- The animation is switched off by prefers-reduced-motion — check that a step still reads without it.
- Picking a leaf does not close the panel: closing belongs to the parent dialog, not to the cascader.
- 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/cascader-004.jsonhttps://vibeui.ru/r/cascader-004.jsonКомпонент самодостаточен: один файл, без зависимостей, собственная палитра в локальных переменных --vibeui-cascader-004-*. Клиентский: путь, направление перехода и выбранный лист живут в useState. Список перемонтируется по key из пути, поэтому CSS-анимация проигрывается заново на каждом шаге, а имя keyframes переключается атрибутом data-direction — вперёд список приезжает справа, назад слева. Кнопка «назад» отключена на верхнем уровне: неактивная цель честнее исчезнувшей, потому что шапка не меняет высоту.
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 { 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>
</>
)
}