Resizable

Stacked Split

A vertical split: editor on top, result below. The separator is horizontal, so the size changes with Up and Down arrows rather than Left and Right.

  • resizable
  • vertical
  • editor
  • preview

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-003?lang=en

.card {
  display: grid;
  gap: 12px;
  padding: 16px;
}

Карточка

Нижняя панель занимает остаток высоты, поэтому результат не исчезает, даже когда редактор растянут до предела.

Редактор занимает 52% высоты.

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-003" (Stacked Split) 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-003.json

Registry item: https://vibeui.ru/r/resizable-003.json
Installs to: components/vibeui/resizable-003.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 vertical split: editor on top, result below. The separator is horizontal, so the size changes with Up and Down arrows rather than Left and Right.

A vertical split of editor and result: a horizontal separator, up and down arrows, Home and End. One file, zero dependencies.

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

<Resizable003
  label="Editor height"
  defaultSize={52}
  min={20}
  max={80}
/>

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-003-* palette — do not swap it for your theme tokens
- aria-orientation="horizontal" on the separator: the axis is declared, not guessed
- up and down arrows for a vertical split — left and right make no sense here
- role="separator" with tabIndex 0 and aria-valuenow: dragging does not replace the keyboard
- the remaining height going to the bottom pane: the result must not vanish under a tall editor
- the dark code surface together with the light surface of the result pane

## 6. You may change
- the separator name through label
- the initial editor height through defaultSize
- the limits through min and max, the keyboard increment through step
- the bar and focus colour through accent

## 7. Rules
- The frame height is set in rem: the component does not stretch to the viewport on its own.
- The code sample is a plain <pre>: the component does no syntax highlighting and should not.
- On a phone a vertical split halves the height — check your minimum values there.
- 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-003.json
https://vibeui.ru/r/resizable-003.json

Один файл, ноль зависимостей, палитра --vibeui-resizable-003-*. Клиентский: useState, useRef и useId. Ось задана честно: разделитель объявлен aria-orientation="horizontal", клавиши — ArrowUp и ArrowDown, курсор row-resize, а размер считается от clientY и высоты рамки. Совпадение направления клавиш с осью движения обязательно: иначе с клавиатуры панель ведёт себя наугад. Верхняя панель хранит высоту в процентах, нижняя занимает остаток. Полоса захватывает указатель через setPointerCapture и запрещает прокрутку под пальцем через touch-action.

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,
} from "react"

export type Resizable003Props = Omit<
  ComponentPropsWithoutRef<"div">,
  "children" | "onChange"
> & {
  label?: string
  defaultSize?: number
  min?: number
  max?: number
  step?: number
  onChange?: (size: number) => void
  accent?: string
}

// Идея компонента: вертикальное разделение — редактор сверху, результат снизу.
// Разделитель здесь горизонтальный, поэтому aria-orientation="horizontal", а
// размер меняют стрелки вверх и вниз: у направления клавиш и у оси движения
// обязана быть одна логика, иначе с клавиатуры панель ведёт себя наугад.
const STYLES = `
:where([data-vibeui-block="resizable-003"]){
--vibeui-resizable-003-bg:oklch(1 0 0);
--vibeui-resizable-003-fg:oklch(0.22 0.014 265);
--vibeui-resizable-003-muted:oklch(0.55 0.014 265);
--vibeui-resizable-003-border:oklch(0.9 0.006 265);
--vibeui-resizable-003-surface:oklch(0.975 0.004 265);
--vibeui-resizable-003-code:oklch(0.28 0.02 265);
--vibeui-resizable-003-accent:oklch(0.6 0.16 145);
--vibeui-resizable-003-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
--vibeui-resizable-003-mono:ui-monospace,SFMono-Regular,Menlo,Consolas,"Liberation Mono",monospace;
}
[data-vibeui-block="resizable-003"]{
box-sizing:border-box;display:flex;flex-direction:column;gap:0.5rem;
width:100%;max-width:26rem;padding:0.875rem;
border:1px solid var(--vibeui-resizable-003-border);border-radius:0.875rem;
background:var(--vibeui-resizable-003-bg);color:var(--vibeui-resizable-003-fg);
font-family:var(--vibeui-resizable-003-font);
}
[data-vibeui-block="resizable-003"] *{box-sizing:border-box}
[data-vibeui-block="resizable-003"] [data-part="frame"]{
display:flex;flex-direction:column;height:15rem;
border:1px solid var(--vibeui-resizable-003-border);border-radius:0.75rem;overflow:hidden;
}
[data-vibeui-block="resizable-003"] [data-part="pane"]{min-height:0;overflow:auto}
[data-vibeui-block="resizable-003"] [data-part="pane"][data-role="rest"]{flex:1}
[data-vibeui-block="resizable-003"] [data-part="pane"][data-role="fixed"]{flex:none}
[data-vibeui-block="resizable-003"] [data-part="editor"]{
background:var(--vibeui-resizable-003-code);color:oklch(0.95 0.01 265);
font-family:var(--vibeui-resizable-003-mono);font-size:0.75rem;line-height:1.6;
padding:0.625rem 0.75rem;margin:0;white-space:pre;
}
[data-vibeui-block="resizable-003"] [data-part="result"]{padding:0.75rem;background:var(--vibeui-resizable-003-bg)}
[data-vibeui-block="resizable-003"] h3{margin:0 0 0.375rem;font-size:0.8125rem;font-weight:650}
[data-vibeui-block="resizable-003"] p{margin:0;font-size:0.75rem;line-height:1.5;color:var(--vibeui-resizable-003-muted)}
/* Разделитель тянется на всю ширину и имеет высоту 0.75rem: горизонтальную
   полосу ловят курсором хуже вертикальной, потому что двигают руку по игреку. */
[data-vibeui-block="resizable-003"] [data-part="split"]{
flex:none;height:0.75rem;position:relative;
background:var(--vibeui-resizable-003-surface);
cursor:row-resize;touch-action:none;
}
[data-vibeui-block="resizable-003"] [data-part="split"]::before{
content:"";position:absolute;inset:calc(50% - 0.5px) 0;background:var(--vibeui-resizable-003-border);
}
[data-vibeui-block="resizable-003"] [data-part="split"]::after{
content:"";position:absolute;left:50%;top:50%;
width:1.75rem;height:0.1875rem;margin:-0.09375rem 0 0 -0.875rem;border-radius:9999px;
background:var(--vibeui-resizable-003-border);
transition:background-color .15s ease;
}
[data-vibeui-block="resizable-003"] [data-part="split"]:hover::after,
[data-vibeui-block="resizable-003"] [data-part="split"][data-dragging="true"]::after{
background:var(--vibeui-resizable-003-accent);
}
[data-vibeui-block="resizable-003"] [data-part="split"]:focus-visible{
outline:2px solid var(--vibeui-resizable-003-accent);outline-offset:-2px;
}
[data-vibeui-block="resizable-003"] [data-part="status"]{
margin:0;font-size:0.75rem;color:var(--vibeui-resizable-003-muted);font-variant-numeric:tabular-nums;
}
@media (prefers-reduced-motion:reduce){[data-vibeui-block="resizable-003"] *{animation:none!important;transition:none!important}}
`

const CODE = `.card {
  display: grid;
  gap: 12px;
  padding: 16px;
}`

/**
 * Вертикальное разделение: редактор сверху, результат снизу, размер меняют
 * перетаскивание и стрелки вверх-вниз. Один файл, ноль зависимостей.
 */
export function Resizable003({
  label = "Высота редактора",
  defaultSize = 52,
  min = 20,
  max = 80,
  step = 5,
  onChange,
  accent,
  className,
  style,
  ...props
}: Resizable003Props) {
  const [size, setSize] = useState(defaultSize)
  const [dragging, setDragging] = useState(false)
  const frame = useRef<HTMLDivElement>(null)
  const editorId = useId()

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

  const apply = (next: number) => {
    const clamped = Math.min(max, Math.max(min, next))

    setSize(clamped)
    onChange?.(clamped)
  }

  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
    if (event.key === "ArrowUp") {
      event.preventDefault()
      apply(size - step)
    } else if (event.key === "ArrowDown") {
      event.preventDefault()
      apply(size + step)
    } else if (event.key === "Home") {
      event.preventDefault()
      apply(min)
    } else if (event.key === "End") {
      event.preventDefault()
      apply(max)
    }
  }

  return (
    <>
      <style href="vibeui-resizable-003" precedence="medium">
        {STYLES}
      </style>
      <div
        {...props}
        data-vibeui-block="resizable-003"
        className={className}
        style={palette}
      >
        <div data-part="frame" ref={frame}>
          <section
            data-part="pane"
            data-role="fixed"
            id={editorId}
            style={{ height: `${size}%` }}
            aria-label="Редактор"
          >
            <pre data-part="editor">{CODE}</pre>
          </section>
          <div
            data-part="split"
            role="separator"
            tabIndex={0}
            aria-orientation="horizontal"
            aria-label={label}
            aria-controls={editorId}
            aria-valuenow={Math.round(size)}
            aria-valuemin={min}
            aria-valuemax={max}
            data-dragging={dragging}
            onKeyDown={onKeyDown}
            onPointerDown={(event) => {
              event.currentTarget.setPointerCapture(event.pointerId)
              setDragging(true)
            }}
            onPointerMove={(event) => {
              if (!dragging) {
                return
              }

              const box = frame.current?.getBoundingClientRect()

              if (!box || box.height === 0) {
                return
              }

              apply(((event.clientY - box.top) / box.height) * 100)
            }}
            onPointerUp={(event) => {
              event.currentTarget.releasePointerCapture(event.pointerId)
              setDragging(false)
            }}
            onPointerCancel={() => setDragging(false)}
          />
          <section data-part="pane" data-role="rest" aria-label="Результат">
            <div data-part="result">
              <h3>Карточка</h3>
              <p>
                Нижняя панель занимает остаток высоты, поэтому результат не
                исчезает, даже когда редактор растянут до предела.
              </p>
            </div>
          </section>
        </div>
        <p data-part="status" role="status">
          Редактор занимает {Math.round(size)}% высоты.
        </p>
      </div>
    </>
  )
}