Skip to content

Resizable

Layout

Two-pane resizing with pointer, keyboard, and RTL support.

HorizontalVerticalPointer resizeKeyboard resize
Open preview

Responsive viewport — resize the window to explore. Direction and density apply only to this preview. Theme applies to the docs too.

Usage

Provide first and second content. value is the first pane’s percentage. The component supports exactly two panes; choose orientation and set bounds in the 0–100 range. Both panes can scroll when their contents exceed their size.

Import
import { Resizable } from "@/components/ui/resizable";

Shipped as editable source in src/components/ui/resizable.tsx in both apps. Copy the implementation below when moving it to another project. Copy the complete example from Source code for all imports and state. Add "use client" when using hooks or event handlers. See installation and theming for project setup.

Component source

The complete implementation is included in both apps. It uses the React, Radix, Lucide, and shared-core dependencies already installed in Apex. Keep your project’s semantic theme tokens when reusing it.

src/components/ui/resizable.tsx
"use client";
import { useId, useRef, useState, type ReactNode } from "react";
import { cn } from "@dashboardpack/core/lib/utils";
export interface ResizableProps {
  first: ReactNode;
  second: ReactNode;
  label?: string;
  orientation?: "horizontal" | "vertical";
  value?: number;
  defaultValue?: number;
  onValueChange?: (value: number) => void;
  min?: number;
  max?: number;
  disabled?: boolean;
  className?: string;
}
export function Resizable({
  first,
  second,
  label = "Resize panels",
  orientation = "horizontal",
  value,
  defaultValue = 50,
  onValueChange,
  min = 20,
  max = 80,
  disabled,
  className,
}: ResizableProps) {
  const [internal, setInternal] = useState(defaultValue);
  const root = useRef<HTMLDivElement>(null);
  const id = useId();
  const lower = Math.max(0, Math.min(100, min));
  const upper = Math.max(lower, Math.min(100, max));
  const clamp = (next: number) =>
    Math.max(lower, Math.min(upper, Number.isFinite(next) ? next : 50));
  const current = clamp(value ?? internal);
  const update = (next: number) => {
    const bounded = clamp(next);
    setInternal(bounded);
    onValueChange?.(bounded);
  };
  const vertical = orientation === "vertical";
  return (
    <div
      ref={root}
      className={cn(
        "grid h-64 w-full min-w-0 overflow-hidden rounded-xl border bg-background",
        className,
      )}
      style={
        vertical
          ? { gridTemplateRows: `${current}fr 8px ${100 - current}fr` }
          : { gridTemplateColumns: `${current}fr 8px ${100 - current}fr` }
      }
    >
      <div id={`${id}-first`} className="min-h-0 min-w-0 overflow-auto">
        {first}
      </div>
      <div
        role="separator"
        tabIndex={disabled ? -1 : 0}
        aria-label={label}
        aria-controls={`${id}-first ${id}-second`}
        aria-orientation={vertical ? "horizontal" : "vertical"}
        aria-valuemin={lower}
        aria-valuemax={upper}
        aria-valuenow={Math.round(current)}
        aria-valuetext={`${Math.round(current)} percent`}
        aria-disabled={disabled || undefined}
        className={cn(
          "relative touch-none bg-muted outline-none focus-visible:bg-primary/30 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
          disabled
            ? "cursor-default"
            : vertical
              ? "cursor-row-resize"
              : "cursor-col-resize",
        )}
        onKeyDown={(event) => {
          if (disabled) return;
          const rtl =
            root.current && getComputedStyle(root.current).direction === "rtl";
          const increase = vertical
            ? "ArrowDown"
            : rtl
              ? "ArrowLeft"
              : "ArrowRight";
          const decrease = vertical
            ? "ArrowUp"
            : rtl
              ? "ArrowRight"
              : "ArrowLeft";
          if ([increase, decrease, "Home", "End"].includes(event.key)) {
            event.preventDefault();
            update(
              event.key === "Home"
                ? lower
                : event.key === "End"
                  ? upper
                  : current +
                    (event.key === increase ? 1 : -1) *
                      (event.shiftKey ? 10 : 2),
            );
          }
        }}
        onPointerDown={(event) => {
          if (!disabled && event.button === 0) {
            event.preventDefault();
            event.currentTarget.focus();
            event.currentTarget.setPointerCapture(event.pointerId);
          }
        }}
        onPointerMove={(event) => {
          if (
            disabled ||
            !event.currentTarget.hasPointerCapture(event.pointerId) ||
            !root.current
          )
            return;
          const rect = root.current.getBoundingClientRect();
          const extent = (vertical ? rect.height : rect.width) - 8;
          if (extent <= 0) return;
          let distance = vertical
            ? event.clientY - rect.top
            : event.clientX - rect.left;
          if (!vertical && getComputedStyle(root.current).direction === "rtl")
            distance = rect.right - event.clientX;
          update(((distance - 4) / extent) * 100);
        }}
        onPointerUp={(event) => {
          if (event.currentTarget.hasPointerCapture(event.pointerId))
            event.currentTarget.releasePointerCapture(event.pointerId);
        }}
      />
      <div id={`${id}-second`} className="min-h-0 min-w-0 overflow-auto">
        {second}
      </div>
    </div>
  );
}

API reference

Common props and defaults. Slot-specific props are named below; native elements also accept their standard HTML attributes. The component TypeScript definitions are the complete type reference.

Resizable props
PropType / DefaultDescription
first / secondReactNode

Default: required

Content for each pane.
value / defaultValuenumber

Default: 50

Controlled or initial first-pane percentage.
onValueChange(value: number) => void

Default: —

Receives bounded percentages.
orientationhorizontal | vertical

Default: horizontal

Pane layout direction.
min / maxnumber

Default: 20 / 80

First-pane percentage bounds.
disabledboolean

Default: false

Disable separator interaction.

Build with these too