bigbullui logobigbullui
Componentnavigation

Resizable

Splitter layout panels with draggable resize handle, min/max limits and collapse button.

Preview

Live Preview
ZONE A // MAIN STAGE
STAGE CONTROLS
DRAG NOTCH DIVIDER →
ZONE B // VIP BALCONY
RESERVED SEATS 01-50
STATUS: ADMITTED

Installation

Tokens guide →

Add to your project via CLI (zero dependencies, code you own):

npx bigbullui add resizable

Or install the full npm package:

npm install bigbullui

Usage

import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@/components/ui/resizable";

<ResizablePanelGroup direction="horizontal">
  <ResizablePanel defaultSize={50}>Left Panel</ResizablePanel>
  <ResizableHandle withHandle />
  <ResizablePanel defaultSize={50}>Right Panel</ResizablePanel>
</ResizablePanelGroup>

Source code

resizable.tsx

Zero dependencies (only React + utils). Copy and paste directly into your project:

"use client";

import * as React from "react";
import { cn } from "./lib/utils";

export interface ResizablePanelGroupProps extends React.HTMLAttributes<HTMLDivElement> {
  direction?: "horizontal" | "vertical";
  onLayout?: (sizes: number[]) => void;
  notched?: boolean;
  className?: string;
  children: React.ReactNode;
}

export interface ResizablePanelProps extends React.HTMLAttributes<HTMLDivElement> {
  id?: string;
  defaultSize?: number;
  minSize?: number;
  maxSize?: number;
  collapsible?: boolean;
  collapsedSize?: number;
  onCollapse?: () => void;
  onExpand?: () => void;
  className?: string;
  children?: React.ReactNode;
  /** Internal index assigned by ResizablePanelGroup */
  _index?: number;
}

export interface ResizableHandleProps extends React.HTMLAttributes<HTMLDivElement> {
  withHandle?: boolean;
  withCollapseButton?: boolean;
  collapseDirection?: "before" | "after";
  disabled?: boolean;
  className?: string;
  children?: React.ReactNode;
  /** Internal index assigned by ResizablePanelGroup */
  _index?: number;
}

interface PanelConfig {
  id: string;
  defaultSize: number;
  minSize: number;
  maxSize: number;
  collapsible: boolean;
  collapsedSize: number;
  onCollapse?: () => void;
  onExpand?: () => void;
}

interface ResizableContextValue {
  direction: "horizontal" | "vertical";
  sizes: number[];
  isDragging: boolean;
  activeHandle: number | null;
  handlePointerDown: (handleIndex: number, e: React.PointerEvent<HTMLDivElement>) => void;
  toggleCollapse: (panelIndex: number) => void;
  stepResize: (handleIndex: number, deltaPercent: number) => void;
  collapsedPanels: Record<number, boolean>;
  notched: boolean;
}

const ResizableContext = React.createContext<ResizableContextValue | null>(null);

function useResizableContext(): ResizableContextValue {
  const ctx = React.useContext(ResizableContext);
  if (!ctx) {
    throw new Error("Resizable subcomponents must be used within a ResizablePanelGroup");
  }
  return ctx;
}

function isPanelElement(child: React.ReactNode): child is React.ReactElement<ResizablePanelProps> {
  if (!React.isValidElement(child)) return false;
  if (child.type === ResizablePanel) return true;
  const childType = child.type as { displayName?: string; name?: string };
  return childType?.displayName === "ResizablePanel" || childType?.name === "ResizablePanel";
}

function isHandleElement(child: React.ReactNode): child is React.ReactElement<ResizableHandleProps> {
  if (!React.isValidElement(child)) return false;
  if (child.type === ResizableHandle) return true;
  const childType = child.type as { displayName?: string; name?: string };
  return childType?.displayName === "ResizableHandle" || childType?.name === "ResizableHandle";
}

export function ResizablePanelGroup({
  direction = "horizontal",
  onLayout,
  notched = true,
  className,
  children,
  style,
  ...props
}: ResizablePanelGroupProps) {
  const groupRef = React.useRef<HTMLDivElement>(null);
  const [isDragging, setIsDragging] = React.useState(false);
  const [activeHandle, setActiveHandle] = React.useState<number | null>(null);
  const [collapsedPanels, setCollapsedPanels] = React.useState<Record<number, boolean>>({});
  const savedSizesRef = React.useRef<Record<number, number>>({});

  // Parse panel configurations from children
  const panelConfigs = React.useMemo(() => {
    const configs: PanelConfig[] = [];
    let explicitSum = 0;
    let unspecifiedCount = 0;

    React.Children.forEach(children, (child) => {
      if (isPanelElement(child)) {
        const ds = child.props.defaultSize;
        if (typeof ds === "number") {
          explicitSum += ds;
        } else {
          unspecifiedCount++;
        }
      }
    });

    const remaining = Math.max(0, 100 - explicitSum);
    const fallbackSize = unspecifiedCount > 0 ? remaining / unspecifiedCount : 50;

    let idx = 0;
    React.Children.forEach(children, (child) => {
      if (isPanelElement(child)) {
        const p = child.props;
        configs.push({
          id: p.id ?? `panel-${idx}`,
          defaultSize: typeof p.defaultSize === "number" ? p.defaultSize : fallbackSize,
          minSize: typeof p.minSize === "number" ? p.minSize : 10,
          maxSize: typeof p.maxSize === "number" ? p.maxSize : 90,
          collapsible: Boolean(p.collapsible),
          collapsedSize: typeof p.collapsedSize === "number" ? p.collapsedSize : 0,
          onCollapse: p.onCollapse,
          onExpand: p.onExpand,
        });
        idx++;
      }
    });

    return configs;
  }, [children]);

  // Sizes percentage array
  const [sizes, setSizes] = React.useState<number[]>(() => {
    if (panelConfigs.length === 0) return [50, 50];
    return panelConfigs.map((c) => c.defaultSize);
  });

  // Synchronize sizes if panel count dynamically changes
  const [prevConfigsLength, setPrevConfigsLength] = React.useState(panelConfigs.length);
  if (panelConfigs.length !== prevConfigsLength) {
    setPrevConfigsLength(panelConfigs.length);
    setSizes(panelConfigs.map((c) => c.defaultSize));
  }


  const sizesRef = React.useRef(sizes);
  const panelConfigsRef = React.useRef(panelConfigs);

  React.useEffect(() => {
    sizesRef.current = sizes;
  }, [sizes]);

  React.useEffect(() => {
    panelConfigsRef.current = panelConfigs;
  }, [panelConfigs]);

  // Pointer drag resizing logic
  const handlePointerDown = React.useCallback(
    (handleIndex: number, e: React.PointerEvent<HTMLDivElement>) => {
      if (!groupRef.current) return;
      e.preventDefault();

      const startClientX = e.clientX;
      const startClientY = e.clientY;
      const initialSizes = [...sizesRef.current];
      const rect = groupRef.current.getBoundingClientRect();
      const isHorizontal = direction === "horizontal";
      const totalPixelSize = isHorizontal ? rect.width : rect.height;

      if (totalPixelSize <= 0) return;

      setIsDragging(true);
      setActiveHandle(handleIndex);

      const onPointerMove = (moveEvent: PointerEvent) => {
        moveEvent.preventDefault();
        const currentPos = isHorizontal ? moveEvent.clientX : moveEvent.clientY;
        const startPos = isHorizontal ? startClientX : startClientY;
        const deltaPx = currentPos - startPos;
        const deltaPercent = (deltaPx / totalPixelSize) * 100;

        const currentConfigs = panelConfigsRef.current;
        const p1Config = currentConfigs[handleIndex];
        const p2Config = currentConfigs[handleIndex + 1];
        if (!p1Config || !p2Config) return;

        const pairTotal = initialSizes[handleIndex] + initialSizes[handleIndex + 1];
        let newP1 = initialSizes[handleIndex] + deltaPercent;

        // Min / Max constraints for panel 1
        if (p1Config.collapsible && newP1 < p1Config.minSize / 2) {
          newP1 = p1Config.collapsedSize;
        } else {
          newP1 = Math.max(p1Config.minSize, Math.min(p1Config.maxSize, newP1));
        }

        let newP2 = pairTotal - newP1;

        // Min / Max constraints for panel 2
        if (p2Config.collapsible && newP2 < p2Config.minSize / 2) {
          newP2 = p2Config.collapsedSize;
          newP1 = pairTotal - newP2;
        } else {
          if (newP2 < p2Config.minSize) {
            newP2 = p2Config.minSize;
            newP1 = pairTotal - newP2;
          } else if (newP2 > p2Config.maxSize) {
            newP2 = p2Config.maxSize;
            newP1 = pairTotal - newP2;
          }
        }

        setSizes((prev) => {
          const next = [...prev];
          next[handleIndex] = Math.round(newP1 * 100) / 100;
          next[handleIndex + 1] = Math.round(newP2 * 100) / 100;
          onLayout?.(next);
          return next;
        });
      };

      const onPointerUp = () => {
        setIsDragging(false);
        setActiveHandle(null);
        window.removeEventListener("pointermove", onPointerMove);
        window.removeEventListener("pointerup", onPointerUp);
        window.removeEventListener("pointercancel", onPointerUp);
        document.body.style.removeProperty("user-select");
        document.body.style.removeProperty("cursor");
      };

      document.body.style.userSelect = "none";
      document.body.style.cursor = isHorizontal ? "col-resize" : "row-resize";
      window.addEventListener("pointermove", onPointerMove);
      window.addEventListener("pointerup", onPointerUp);
      window.addEventListener("pointercancel", onPointerUp);
    },
    [direction, onLayout]
  );

  // Keyboard stepping
  const stepResize = React.useCallback(
    (handleIndex: number, deltaPercent: number) => {
      const currentConfigs = panelConfigsRef.current;
      const p1Config = currentConfigs[handleIndex];
      const p2Config = currentConfigs[handleIndex + 1];
      if (!p1Config || !p2Config) return;

      setSizes((prev) => {
        const next = [...prev];
        const pairTotal = next[handleIndex] + next[handleIndex + 1];
        let newP1 = next[handleIndex] + deltaPercent;

        newP1 = Math.max(p1Config.minSize, Math.min(p1Config.maxSize, newP1));
        let newP2 = pairTotal - newP1;

        if (newP2 < p2Config.minSize) {
          newP2 = p2Config.minSize;
          newP1 = pairTotal - newP2;
        } else if (newP2 > p2Config.maxSize) {
          newP2 = p2Config.maxSize;
          newP1 = pairTotal - newP2;
        }

        next[handleIndex] = Math.round(newP1 * 100) / 100;
        next[handleIndex + 1] = Math.round(newP2 * 100) / 100;
        onLayout?.(next);
        return next;
      });
    },
    [onLayout]
  );

  // Collapse / Expand toggle
  const toggleCollapse = React.useCallback(
    (panelIndex: number) => {
      const currentConfigs = panelConfigsRef.current;
      const config = currentConfigs[panelIndex];
      if (!config || !config.collapsible) return;

      setSizes((prev) => {
        const next = [...prev];
        const isCurrentlyCollapsed = next[panelIndex] <= config.collapsedSize + 1;
        const neighborIndex = panelIndex === 0 ? 1 : panelIndex - 1;
        const pairTotal = next[panelIndex] + next[neighborIndex];

        if (isCurrentlyCollapsed) {
          // Restore
          const saved = savedSizesRef.current[panelIndex];
          const restoreSize = saved || config.defaultSize || 30;
          const minNeighbor = currentConfigs[neighborIndex]?.minSize ?? 10;
          const actualRestore = Math.min(restoreSize, pairTotal - minNeighbor);

          next[panelIndex] = Math.round(actualRestore * 100) / 100;
          next[neighborIndex] = Math.round((pairTotal - actualRestore) * 100) / 100;
          setCollapsedPanels((c) => ({ ...c, [panelIndex]: false }));
          config.onExpand?.();
        } else {
          // Collapse
          savedSizesRef.current[panelIndex] = next[panelIndex];
          const collapsed = config.collapsedSize;
          next[panelIndex] = collapsed;
          next[neighborIndex] = Math.round((pairTotal - collapsed) * 100) / 100;
          setCollapsedPanels((c) => ({ ...c, [panelIndex]: true }));
          config.onCollapse?.();
        }

        onLayout?.(next);
        return next;
      });
    },
    [onLayout]
  );

  // Map over children to inject indices
  let panelIdx = 0;
  let handleIdx = 0;
  const mappedChildren = React.Children.map(children, (child) => {
    if (!React.isValidElement(child)) return child;

    if (isPanelElement(child)) {
      const currentIdx = panelIdx++;
      return React.cloneElement(child, {
        _index: currentIdx,
      });
    }

    if (isHandleElement(child)) {
      const currentIdx = handleIdx++;
      return React.cloneElement(child, {
        _index: currentIdx,
      });
    }

    return child;
  });

  return (
    <ResizableContext.Provider
      value={{
        direction,
        sizes,
        isDragging,
        activeHandle,
        handlePointerDown,
        toggleCollapse,
        stepResize,
        collapsedPanels,
        notched,
      }}
    >
      <div
        ref={groupRef}
        data-direction={direction}
        data-dragging={isDragging}
        style={style}
        className={cn(
          "relative flex h-full w-full overflow-hidden rounded-lg border-2 border-foreground bg-card text-card-foreground shadow-sm select-none",
          direction === "horizontal" ? "flex-row" : "flex-col",
          className
        )}
        {...props}
      >
        {mappedChildren}
      </div>
    </ResizableContext.Provider>
  );
}

export function ResizablePanel({
  id,
  defaultSize,
  minSize = 10,
  maxSize = 90,
  collapsible = false,
  collapsedSize = 0,
  className,
  children,
  _index,
  style,
  ...props
}: ResizablePanelProps) {
  const ctx = useResizableContext();
  const index = _index ?? 0;
  const currentSize = ctx.sizes[index] ?? defaultSize ?? 50;
  const isCollapsed = Boolean(ctx.collapsedPanels[index]);
  const isHorizontal = ctx.direction === "horizontal";

  const panelStyle: React.CSSProperties = {
    ...style,
    flexGrow: currentSize,
    flexShrink: 1,
    flexBasis: 0,
    overflow: "hidden",
    ...(isHorizontal
      ? { minWidth: isCollapsed ? 0 : undefined }
      : { minHeight: isCollapsed ? 0 : undefined }),
  };

  return (
    <div
      data-panel-id={id}
      data-panel-index={index}
      data-collapsed={isCollapsed}
      data-min-size={minSize}
      data-max-size={maxSize}
      data-collapsible={collapsible}
      data-collapsed-size={collapsedSize}
      style={panelStyle}
      className={cn(
        "relative flex flex-col min-w-0 min-h-0 bg-card text-card-foreground",
        !ctx.isDragging && "transition-[flex-grow,flex-basis,opacity] duration-200 ease-out",
        isCollapsed && "opacity-0 pointer-events-none select-none",
        className
      )}
      {...props}
    >
      {children}
    </div>
  );
}
ResizablePanel.displayName = "ResizablePanel";

export function ResizableHandle({
  withHandle = true,
  withCollapseButton = false,
  collapseDirection = "before",
  disabled = false,
  className,
  children,
  _index,
  ...props
}: ResizableHandleProps) {
  const ctx = useResizableContext();
  const handleIndex = _index ?? 0;
  const isHorizontal = ctx.direction === "horizontal";
  const isActive = ctx.activeHandle === handleIndex;

  const collapseIndex = collapseDirection === "before" ? handleIndex : handleIndex + 1;
  const isTargetCollapsed = Boolean(ctx.collapsedPanels[collapseIndex]);

  const handlePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
    if (disabled) return;
    ctx.handlePointerDown(handleIndex, e);
  };

  const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
    if (disabled) return;
    const step = e.shiftKey ? 5 : 2;

    if (isHorizontal) {
      if (e.key === "ArrowLeft") {
        e.preventDefault();
        ctx.stepResize(handleIndex, -step);
      } else if (e.key === "ArrowRight") {
        e.preventDefault();
        ctx.stepResize(handleIndex, step);
      }
    } else {
      if (e.key === "ArrowUp") {
        e.preventDefault();
        ctx.stepResize(handleIndex, -step);
      } else if (e.key === "ArrowDown") {
        e.preventDefault();
        ctx.stepResize(handleIndex, step);
      }
    }

    if (e.key === "Enter" || e.key === " ") {
      e.preventDefault();
      ctx.toggleCollapse(collapseIndex);
    }
  };

  return (
    <div
      role="separator"
      tabIndex={disabled ? -1 : 0}
      aria-orientation={isHorizontal ? "vertical" : "horizontal"}
      aria-valuenow={Math.round(ctx.sizes[handleIndex] ?? 50)}
      aria-valuemin={0}
      aria-valuemax={100}
      data-handle-index={handleIndex}
      data-active={isActive}
      onPointerDown={handlePointerDown}
      onKeyDown={handleKeyDown}
      className={cn(
        "group relative flex shrink-0 items-center justify-center select-none touch-none",
        disabled ? "pointer-events-none opacity-50" : "cursor-col-resize hover:bg-accent/10",
        isHorizontal
          ? "w-4 cursor-col-resize flex-col justify-center"
          : "h-4 cursor-row-resize flex-row justify-center",
        isActive && "bg-accent/15",
        className
      )}
      {...props}
    >
      {/* Ticket Cutout Notches at Divider Terminal Ends */}
      {ctx.notched && (
        <>
          {isHorizontal ? (
            <>
              <div
                aria-hidden="true"
                className="absolute -top-2 left-1/2 -translate-x-1/2 size-4 rounded-full border-2 border-foreground bg-background z-20 pointer-events-none"
              />
              <div
                aria-hidden="true"
                className="absolute -bottom-2 left-1/2 -translate-x-1/2 size-4 rounded-full border-2 border-foreground bg-background z-20 pointer-events-none"
              />
            </>
          ) : (
            <>
              <div
                aria-hidden="true"
                className="absolute -left-2 top-1/2 -translate-y-1/2 size-4 rounded-full border-2 border-foreground bg-background z-20 pointer-events-none"
              />
              <div
                aria-hidden="true"
                className="absolute -right-2 top-1/2 -translate-y-1/2 size-4 rounded-full border-2 border-foreground bg-background z-20 pointer-events-none"
              />
            </>
          )}
        </>
      )}

      {/* Perforated Dashed Center Line */}
      <div
        aria-hidden="true"
        className={cn(
          "absolute transition-colors pointer-events-none",
          isHorizontal
            ? "inset-y-0 left-1/2 -translate-x-1/2 w-0 border-r-2 border-dashed border-border group-hover:border-foreground group-data-[active=true]:border-accent"
            : "inset-x-0 top-1/2 -translate-y-1/2 h-0 border-b-2 border-dashed border-border group-hover:border-foreground group-data-[active=true]:border-accent"
        )}
      />

      {/* Tactile Perforated Ticket Grip Pill */}
      {withHandle && (
        <div
          className={cn(
            "z-10 rounded-xs border-2 border-border bg-card shadow-xs transition-all",
            "group-hover:border-foreground group-hover:scale-105",
            isActive && "border-accent bg-accent/20 scale-110",
            isHorizontal ? "px-1 py-1.5" : "px-1.5 py-1"
          )}
        >
          <div
            className={cn(
              "flex items-center justify-center gap-0.5",
              isHorizontal ? "flex-col" : "flex-row"
            )}
          >
            <span className="size-1 rounded-full bg-foreground/60 group-hover:bg-foreground" />
            <span className="size-1 rounded-full bg-foreground/60 group-hover:bg-foreground" />
            <span className="size-1 rounded-full bg-foreground/60 group-hover:bg-foreground" />
          </div>
        </div>
      )}

      {/* Quick Collapse / Expand Ticket Button */}
      {withCollapseButton && (
        <button
          type="button"
          tabIndex={-1}
          onClick={(e) => {
            e.stopPropagation();
            ctx.toggleCollapse(collapseIndex);
          }}
          aria-label={isTargetCollapsed ? "Expand panel" : "Collapse panel"}
          className={cn(
            "z-10 flex size-5 cursor-pointer items-center justify-center rounded-xs border-2 border-border bg-card font-mono text-[9px] font-bold text-foreground shadow-xs transition-all",
            "hover:border-foreground hover:bg-accent hover:text-accent-foreground hover:scale-110 active:scale-95",
            isHorizontal ? "mt-2" : "ml-2"
          )}
        >
          {isHorizontal ? (
            collapseDirection === "before" ? (
              isTargetCollapsed ? "›" : "‹"
            ) : (
              isTargetCollapsed ? "‹" : "›"
            )
          ) : (
            collapseDirection === "before" ? (
              isTargetCollapsed ? "▼" : "▲"
            ) : (
              isTargetCollapsed ? "▲" : "▼"
            )
          )}
        </button>
      )}

      {/* Live Percentage Stub Readout Badge while dragging */}
      {isActive && (
        <div
          aria-hidden="true"
          className={cn(
            "absolute z-30 pointer-events-none whitespace-nowrap rounded-xs border-2 border-foreground bg-card px-2 py-0.5 font-mono text-[9px] font-bold uppercase tracking-wider text-foreground shadow-md animate-in fade-in zoom-in-95",
            isHorizontal ? "top-3 left-1/2 -translate-x-1/2" : "left-3 top-1/2 -translate-y-1/2"
          )}
        >
          {Math.round(ctx.sizes[handleIndex] ?? 50)}% | {Math.round(ctx.sizes[handleIndex + 1] ?? 50)}%
        </div>
      )}

      {children}
    </div>
  );
}
ResizableHandle.displayName = "ResizableHandle";

Props

PropTypeDescription
direction"horizontal" | "vertical"Layout orientation of panels (default 'horizontal').
defaultSizenumberDefault initial panel percentage size (on ResizablePanel).
minSizenumberMinimum allowable percentage size (on ResizablePanel).
maxSizenumberMaximum allowable percentage size (on ResizablePanel).
withHandlebooleanRenders tactile grab handle notch on resize border (on ResizableHandle).