Resizable

Three Panes

Three panes and two separators: the state holds boundary positions, so each boundary is simply clamped between its neighbours and the panes never drift apart.

  • resizable
  • splitter
  • three-panes
  • keyboard

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/resizable-002?lang=en

Проекты

  • Витрина
  • Каталог
  • Документы

Задачи

  • Собрать реестр
  • Проверить превью
  • Обновить документацию

Описание

Правая панель забирает остаток ширины, поэтому сумма всегда сходится к сотне процентов без пересчёта.

26% · 40% · 34%

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 "resizable-002" (Three Panes) 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/resizable-002.json

Registry item: https://vibeui.ru/r/resizable-002.json
Installs to: components/vibeui/resizable-002.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
Three panes and two separators: the state holds boundary positions, so each boundary is simply clamped between its neighbours and the panes never drift apart.

Three panes with two separators: boundaries clamped between neighbours, both bars work by mouse and from the keyboard. One file, zero dependencies.

## 3. How to use it
import { Resizable002 } from "@/components/vibeui/resizable-002"

<Resizable002
  defaultLeft={26}
  defaultMiddle={40}
  min={14}
  step={3}
/>

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-resizable-002-* palette — do not swap it for your theme tokens
- storing boundaries instead of widths: with three widths rounding pulls the panes apart
- clamping a boundary between its neighbour and the minimum — otherwise panes collapse into each other
- role="separator" with aria-valuenow on both bars, not just one
- distinct separator names: two identical "boundaries" are indistinguishable from the keyboard
- min-width:0 on the panes, otherwise their content prevents shrinking

## 6. You may change
- the initial sizes through defaultLeft and defaultMiddle
- the minimum pane share through min and the keyboard increment through step
- the separator name through label
- the active bar colour through accent

## 7. Rules
- aria-valuenow here is a boundary position, not a pane width: that is more honest with two bars.
- min is expressed in percent and applies equally to all three panes.
- The third pane stores no size: do not try to set its width, it takes the remainder.
- 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/resizable-002.json
https://vibeui.ru/r/resizable-002.json

Один файл, ноль зависимостей, палитра --vibeui-resizable-002-*. Клиентский: useState с массивом из двух границ, useRef на рамку, useId на идентификаторы панелей. Модель данных выбрана осознанно: хранятся не три ширины, а две границы — тогда ограничение сводится к зажиму между соседней границей и минимумом, а сумма всегда сходится, потому что третья панель забирает остаток. Оба разделителя — полноценные role="separator" с tabIndex 0, aria-valuenow/min/max, aria-controls и обработкой стрелок, Home и End; тяга держится на setPointerCapture.

Component source

The same file your agent installs. Here in case you would rather copy it by hand.

"use client"

import { useId, useRef, useState } from "react"
import type {
  ComponentPropsWithoutRef,
  CSSProperties,
  KeyboardEvent,
  PointerEvent,
} from "react"

export type Resizable002Props = Omit<
  ComponentPropsWithoutRef<"div">,
  "children" | "onChange"
> & {
  label?: string
  min?: number
  step?: number
  defaultLeft?: number
  defaultMiddle?: number
  onChange?: (sizes: number[]) => void
  accent?: string
}

// Идея компонента: три панели и два разделителя. Состояние хранится не как
// ширины панелей, а как положения границ — тогда соседняя панель не может
// «съехать» вслед за чужим округлением, а ограничения превращаются в простой
// зажим границы между соседями.
const STYLES = `
:where([data-vibeui-block="resizable-002"]){
--vibeui-resizable-002-bg:oklch(1 0 0);
--vibeui-resizable-002-fg:oklch(0.22 0.014 265);
--vibeui-resizable-002-muted:oklch(0.55 0.014 265);
--vibeui-resizable-002-border:oklch(0.9 0.006 265);
--vibeui-resizable-002-surface:oklch(0.975 0.004 265);
--vibeui-resizable-002-accent:oklch(0.56 0.15 195);
--vibeui-resizable-002-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}
[data-vibeui-block="resizable-002"]{
box-sizing:border-box;display:flex;flex-direction:column;gap:0.5rem;
width:100%;max-width:38rem;padding:0.875rem;
border:1px solid var(--vibeui-resizable-002-border);border-radius:0.875rem;
background:var(--vibeui-resizable-002-bg);color:var(--vibeui-resizable-002-fg);
font-family:var(--vibeui-resizable-002-font);
}
[data-vibeui-block="resizable-002"] *{box-sizing:border-box}
[data-vibeui-block="resizable-002"] [data-part="frame"]{
display:flex;align-items:stretch;height:10.5rem;
border:1px solid var(--vibeui-resizable-002-border);border-radius:0.75rem;overflow:hidden;
}
[data-vibeui-block="resizable-002"] [data-part="pane"]{
min-width:0;padding:0.625rem;overflow:auto;background:var(--vibeui-resizable-002-bg);
}
[data-vibeui-block="resizable-002"] [data-part="pane"][data-role="rest"]{flex:1}
[data-vibeui-block="resizable-002"] [data-part="pane"][data-role="fixed"]{flex:none}
[data-vibeui-block="resizable-002"] h3{
margin:0 0 0.3125rem;font-size:0.6875rem;font-weight:700;letter-spacing:0.04em;text-transform:uppercase;
color:var(--vibeui-resizable-002-muted);
}
[data-vibeui-block="resizable-002"] ul{list-style:none;margin:0;padding:0;display:grid;gap:0.25rem}
[data-vibeui-block="resizable-002"] li{
font-size:0.75rem;line-height:1.3;padding:0.25rem 0.375rem;border-radius:0.375rem;
background:var(--vibeui-resizable-002-surface);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
}
[data-vibeui-block="resizable-002"] p{margin:0;font-size:0.75rem;line-height:1.5;color:var(--vibeui-resizable-002-muted)}
[data-vibeui-block="resizable-002"] [data-part="split"]{
flex:none;width:0.6875rem;position:relative;
background:var(--vibeui-resizable-002-surface);
cursor:col-resize;touch-action:none;
}
[data-vibeui-block="resizable-002"] [data-part="split"]::before{
content:"";position:absolute;inset:0 calc(50% - 0.5px);background:var(--vibeui-resizable-002-border);
transition:background-color .15s ease;
}
[data-vibeui-block="resizable-002"] [data-part="split"]:hover::before,
[data-vibeui-block="resizable-002"] [data-part="split"][data-dragging="true"]::before{
inset:0 calc(50% - 1.5px);background:var(--vibeui-resizable-002-accent);
}
[data-vibeui-block="resizable-002"] [data-part="split"]:focus-visible{
outline:2px solid var(--vibeui-resizable-002-accent);outline-offset:-2px;
}
[data-vibeui-block="resizable-002"] [data-part="status"]{
margin:0;font-size:0.75rem;color:var(--vibeui-resizable-002-muted);font-variant-numeric:tabular-nums;
}
@media (prefers-reduced-motion:reduce){[data-vibeui-block="resizable-002"] *{animation:none!important;transition:none!important}}
`

/**
 * Три панели и два разделителя: состояние — положения границ, каждая
 * зажата между соседями. Мышь и клавиатура равноправны. Один файл.
 */
export function Resizable002({
  label = "Граница",
  min = 14,
  step = 3,
  defaultLeft = 26,
  defaultMiddle = 40,
  onChange,
  accent,
  className,
  style,
  ...props
}: Resizable002Props) {
  const [edges, setEdges] = useState([defaultLeft, defaultLeft + defaultMiddle])
  const [dragging, setDragging] = useState(-1)
  const frame = useRef<HTMLDivElement>(null)

  const first = useId()
  const second = useId()
  const third = useId()
  const paneIds = [first, second, third]

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

  const limits = (index: number) => ({
    low: index === 0 ? min : edges[0] + min,
    high: index === 0 ? edges[1] - min : 100 - min,
  })

  const apply = (index: number, value: number) => {
    const { low, high } = limits(index)
    const next = [...edges]

    next[index] = Math.min(high, Math.max(low, value))
    setEdges(next)
    onChange?.([next[0], next[1] - next[0], 100 - next[1]])
  }

  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>, index: number) => {
    const { low, high } = limits(index)

    if (event.key === "ArrowLeft") {
      event.preventDefault()
      apply(index, edges[index] - step)
    } else if (event.key === "ArrowRight") {
      event.preventDefault()
      apply(index, edges[index] + step)
    } else if (event.key === "Home") {
      event.preventDefault()
      apply(index, low)
    } else if (event.key === "End") {
      event.preventDefault()
      apply(index, high)
    }
  }

  const widths = [edges[0], edges[1] - edges[0]]

  const splitProps = (index: number) => ({
    "data-part": "split",
    role: "separator" as const,
    tabIndex: 0,
    "aria-orientation": "vertical" as const,
    "aria-label": `${label} ${index + 1}`,
    "aria-controls": paneIds[index],
    "aria-valuenow": Math.round(edges[index]),
    "aria-valuemin": Math.round(limits(index).low),
    "aria-valuemax": Math.round(limits(index).high),
    "data-dragging": dragging === index,
    onKeyDown: (event: KeyboardEvent<HTMLDivElement>) =>
      onKeyDown(event, index),
    onPointerDown: (event: PointerEvent<HTMLDivElement>) => {
      event.currentTarget.setPointerCapture(event.pointerId)
      setDragging(index)
    },
    // Рамку меряем прямо в обработчике: ref читается вне рендера, иначе
    // React не гарантирует, что значение соответствует показанной разметке.
    onPointerMove: (event: PointerEvent<HTMLDivElement>) => {
      if (dragging !== index) return

      const box = frame.current?.getBoundingClientRect()

      if (!box || box.width === 0) return

      apply(index, ((event.clientX - box.left) / box.width) * 100)
    },
    onPointerUp: (event: PointerEvent<HTMLDivElement>) => {
      event.currentTarget.releasePointerCapture(event.pointerId)
      setDragging(-1)
    },
    onPointerCancel: () => setDragging(-1),
  })

  return (
    <>
      <style href="vibeui-resizable-002" precedence="medium">
        {STYLES}
      </style>
      <div
        {...props}
        data-vibeui-block="resizable-002"
        className={className}
        style={palette}
      >
        <div data-part="frame" ref={frame}>
          <section
            data-part="pane"
            data-role="fixed"
            id={paneIds[0]}
            style={{ width: `${widths[0]}%` }}
            aria-label="Проекты"
          >
            <h3>Проекты</h3>
            <ul>
              <li>Витрина</li>
              <li>Каталог</li>
              <li>Документы</li>
            </ul>
          </section>
          <div {...splitProps(0)} />
          <section
            data-part="pane"
            data-role="fixed"
            id={paneIds[1]}
            style={{ width: `${widths[1]}%` }}
            aria-label="Задачи"
          >
            <h3>Задачи</h3>
            <ul>
              <li>Собрать реестр</li>
              <li>Проверить превью</li>
              <li>Обновить документацию</li>
            </ul>
          </section>
          <div {...splitProps(1)} />
          <section
            data-part="pane"
            data-role="rest"
            id={paneIds[2]}
            aria-label="Описание"
          >
            <h3>Описание</h3>
            <p>
              Правая панель забирает остаток ширины, поэтому сумма всегда
              сходится к сотне процентов без пересчёта.
            </p>
          </section>
        </div>
        <p data-part="status" role="status">
          {Math.round(widths[0])}% · {Math.round(widths[1])}% ·{" "}
          {Math.round(100 - edges[1])}%
        </p>
      </div>
    </>
  )
}