Resizable

Clamped Sidebar

A pane with real pixel limits: the size is clamped between a minimum and a maximum, and hitting a limit is shown by the bar's colour and a caption rather than silence.

  • resizable
  • min-max
  • sidebar
  • pixels

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

Заказ № 4821

Основная область забирает остаток ширины, поэтому меню не может выдавить содержимое за край.

Меню — 200 px

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-004" (Clamped Sidebar) 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-004.json

Registry item: https://vibeui.ru/r/resizable-004.json
Installs to: components/vibeui/resizable-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 pane with real pixel limits: the size is clamped between a minimum and a maximum, and hitting a limit is shown by the bar's colour and a caption rather than silence.

A sidebar with pixel limits: the size is clamped between min and max, and reaching a limit is announced by colour and a caption. One file, zero dependencies.

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

<Resizable004
  label="Menu width"
  defaultSize={200}
  min={144}
  max={288}
/>

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-004-* palette — do not swap it for your theme tokens
- limits in pixels: a percentage of a narrow screen does not guarantee readable captions
- a visible reaction to hitting the limit — without it the interface looks frozen
- aria-valuetext with units: a bare pixel number means little by ear
- role="separator" with tabIndex 0 and arrow, Home and End handling
- the remaining width going to the main area: the menu must not squeeze the content out

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

## 7. Rules
- min and max are pixels, not rem: recompute them yourself for a large system font.
- The limit badge appears only at the edge of the range and disappears on the first step back.
- The component does not persist the width between sessions: store the value outside for that.
- 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-004.json
https://vibeui.ru/r/resizable-004.json

Один файл, ноль зависимостей, палитра --vibeui-resizable-004-*. Клиентский: useState, useRef, useId. Размер хранится в пикселях, а не в процентах: меню шириной «двадцать процентов» на узком экране превращается в обрубок, а минимум в пикселях держит подписи читаемыми. aria-valuenow отдаёт то же число, что и в стиле, а aria-valuetext проговаривает единицы. Упор в предел не проглатывается: корень получает data-limit, полоса красится, рядом появляется плашка с числом — иначе перетаскивание выглядит как зависший интерфейс.

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 Resizable004Props = Omit<
  ComponentPropsWithoutRef<"div">,
  "children" | "onChange"
> & {
  label?: string
  defaultSize?: number
  min?: number
  max?: number
  step?: number
  onChange?: (size: number) => void
  accent?: string
}

// Идея компонента: панель с настоящими пределами в пикселях, а не в процентах.
// Меню шириной в проценты на узком экране превращается в обрубок, поэтому
// размер здесь хранится в пикселях и зажимается между min и max. Упор в предел
// не проглатывается молча: разделитель меняет цвет, а подпись называет предел.
const STYLES = `
:where([data-vibeui-block="resizable-004"]){
--vibeui-resizable-004-bg:oklch(1 0 0);
--vibeui-resizable-004-fg:oklch(0.22 0.014 265);
--vibeui-resizable-004-muted:oklch(0.55 0.014 265);
--vibeui-resizable-004-border:oklch(0.9 0.006 265);
--vibeui-resizable-004-surface:oklch(0.975 0.004 265);
--vibeui-resizable-004-accent:oklch(0.55 0.17 275);
--vibeui-resizable-004-limit:oklch(0.62 0.16 55);
--vibeui-resizable-004-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}
[data-vibeui-block="resizable-004"]{
box-sizing:border-box;display:flex;flex-direction:column;gap:0.5rem;
width:100%;max-width:34rem;padding:0.875rem;
border:1px solid var(--vibeui-resizable-004-border);border-radius:0.875rem;
background:var(--vibeui-resizable-004-bg);color:var(--vibeui-resizable-004-fg);
font-family:var(--vibeui-resizable-004-font);
}
[data-vibeui-block="resizable-004"] *{box-sizing:border-box}
[data-vibeui-block="resizable-004"] [data-part="frame"]{
display:flex;align-items:stretch;height:11rem;
border:1px solid var(--vibeui-resizable-004-border);border-radius:0.75rem;overflow:hidden;
}
[data-vibeui-block="resizable-004"] [data-part="side"]{
flex:none;min-width:0;padding:0.75rem;overflow:auto;background:var(--vibeui-resizable-004-surface);
}
[data-vibeui-block="resizable-004"] [data-part="main"]{
flex:1;min-width:0;padding:0.75rem;overflow:auto;background:var(--vibeui-resizable-004-bg);
}
[data-vibeui-block="resizable-004"] h3{margin:0 0 0.375rem;font-size:0.8125rem;font-weight:650}
[data-vibeui-block="resizable-004"] ul{list-style:none;margin:0;padding:0;display:grid;gap:0.3125rem}
[data-vibeui-block="resizable-004"] li{
font-size:0.75rem;line-height:1.3;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
color:var(--vibeui-resizable-004-muted);
}
[data-vibeui-block="resizable-004"] p{margin:0;font-size:0.75rem;line-height:1.5;color:var(--vibeui-resizable-004-muted)}
[data-vibeui-block="resizable-004"] [data-part="split"]{
flex:none;width:0.75rem;position:relative;
background:var(--vibeui-resizable-004-surface);cursor:col-resize;touch-action:none;
}
[data-vibeui-block="resizable-004"] [data-part="split"]::before{
content:"";position:absolute;inset:0 calc(50% - 1px);border-radius:9999px;
background:var(--vibeui-resizable-004-border);
transition:background-color .15s ease;
}
[data-vibeui-block="resizable-004"] [data-part="split"]:hover::before{background:var(--vibeui-resizable-004-accent)}
[data-vibeui-block="resizable-004"] [data-part="split"]:focus-visible{
outline:2px solid var(--vibeui-resizable-004-accent);outline-offset:-2px;
}
/* Упор в предел красит полосу: без этого перетаскивание выглядит как
   зависший интерфейс — курсор едет, панель стоит. */
[data-vibeui-block="resizable-004"][data-limit="true"] [data-part="split"]::before{
background:var(--vibeui-resizable-004-limit);
}
[data-vibeui-block="resizable-004"] [data-part="status"]{
display:flex;align-items:center;gap:0.5rem;margin:0;
font-size:0.75rem;color:var(--vibeui-resizable-004-muted);font-variant-numeric:tabular-nums;
}
[data-vibeui-block="resizable-004"] [data-part="badge"]{
display:inline-flex;align-items:center;padding:0.0625rem 0.375rem;border-radius:9999px;
background:color-mix(in oklab,var(--vibeui-resizable-004-limit) 18%,white);
color:oklch(0.42 0.12 55);font-size:0.6875rem;font-weight:650;
}
@media (prefers-reduced-motion:reduce){[data-vibeui-block="resizable-004"] *{animation:none!important;transition:none!important}}
`

/**
 * Панель с пределами в пикселях: размер зажат между min и max, а упор в
 * предел показан цветом и подписью. Один файл, ноль зависимостей.
 */
export function Resizable004({
  label = "Ширина меню",
  defaultSize = 200,
  min = 144,
  max = 288,
  step = 16,
  onChange,
  accent,
  className,
  style,
  ...props
}: Resizable004Props) {
  const [size, setSize] = useState(defaultSize)
  const [dragging, setDragging] = useState(false)
  const frame = useRef<HTMLDivElement>(null)
  const sideId = useId()

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

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

    setSize(clamped)
    onChange?.(clamped)
  }

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

  const atLimit = size <= min || size >= max

  return (
    <>
      <style href="vibeui-resizable-004" precedence="medium">
        {STYLES}
      </style>
      <div
        {...props}
        data-vibeui-block="resizable-004"
        data-limit={atLimit}
        className={className}
        style={palette}
      >
        <div data-part="frame" ref={frame}>
          <nav
            data-part="side"
            id={sideId}
            style={{ width: `${size}px` }}
            aria-label="Разделы"
          >
            <h3>Разделы</h3>
            <ul>
              <li>Заказы и возвраты</li>
              <li>Склад</li>
              <li>Отчёты за период</li>
              <li>Настройки доставки</li>
            </ul>
          </nav>
          <div
            data-part="split"
            role="separator"
            tabIndex={0}
            aria-orientation="vertical"
            aria-label={label}
            aria-controls={sideId}
            aria-valuenow={size}
            aria-valuemin={min}
            aria-valuemax={max}
            aria-valuetext={`${size} пикселей`}
            data-dragging={dragging}
            onKeyDown={onKeyDown}
            onPointerDown={(event) => {
              event.currentTarget.setPointerCapture(event.pointerId)
              setDragging(true)
            }}
            onPointerMove={(event: PointerEvent<HTMLDivElement>) => {
              if (!dragging) {
                return
              }

              const box = frame.current?.getBoundingClientRect()

              if (!box) {
                return
              }

              apply(event.clientX - box.left)
            }}
            onPointerUp={(event) => {
              event.currentTarget.releasePointerCapture(event.pointerId)
              setDragging(false)
            }}
            onPointerCancel={() => setDragging(false)}
          />
          <main data-part="main">
            <h3>Заказ № 4821</h3>
            <p>
              Основная область забирает остаток ширины, поэтому меню не может
              выдавить содержимое за край.
            </p>
          </main>
        </div>
        <p data-part="status" role="status">
          Меню — {size} px
          {size <= min ? <span data-part="badge">минимум {min}</span> : null}
          {size >= max ? <span data-part="badge">максимум {max}</span> : null}
        </p>
      </div>
    </>
  )
}