Resizable

Accessible resizable panel groups with keyboard support.

The Resizable component provides resizable panel groups that can be dragged to adjust sizes. It supports horizontal and vertical layouts, keyboard navigation, and accessibility features. Built from scratch using React and native HTML elements. No dependencies on any UI library.

Code

TypeScript: Copy this code into components/ui/resizable.tsx:

tsx
"use client"

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

interface ResizableContextValue {
  direction: "horizontal" | "vertical"
  panels: string[]
  sizes: Record<string, number>
  setSizes: (sizes: Record<string, number>) => void
  registerPanel: (id: string, defaultSize?: number) => void
  unregisterPanel: (id: string) => void
}

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

const useResizable = () => {
  const context = React.useContext(ResizableContext)
  if (!context) {
    throw new Error("Resizable components must be used within ResizablePanelGroup")
  }
  return context
}

interface ResizablePanelGroupProps extends React.HTMLAttributes<HTMLDivElement> {
  direction?: "horizontal" | "vertical"
  children: React.ReactNode
}

const ResizablePanelGroup = React.forwardRef<HTMLDivElement, ResizablePanelGroupProps>(
  ({ className, direction = "horizontal", children, ...props }, ref) => {
    const [panels, setPanels] = React.useState<string[]>([])
    const [sizes, setSizes] = React.useState<Record<string, number>>({})

    const registerPanel = React.useCallback((id: string, defaultSize?: number) => {
      setPanels((prev) => {
        if (!prev.includes(id)) {
          return [...prev, id]
        }
        return prev
      })
      if (defaultSize !== undefined) {
        setSizes((prev) => ({
          ...prev,
          [id]: defaultSize,
        }))
      }
    }, [])

    const unregisterPanel = React.useCallback((id: string) => {
      setPanels((prev) => prev.filter((p) => p !== id))
      setSizes((prev) => {
        const newSizes = { ...prev }
        delete newSizes[id]
        return newSizes
      })
    }, [])

    React.useEffect(() => {
      if (panels.length > 0) {
        const totalSize = panels.reduce((sum, id) => sum + (sizes[id] || 0), 0)
        if (totalSize === 0 || totalSize !== 100) {
          const defaultSize = 100 / panels.length
          const newSizes: Record<string, number> = {}
          panels.forEach((id) => {
            newSizes[id] = sizes[id] || defaultSize
          })
          const normalizedTotal = Object.values(newSizes).reduce((sum, size) => sum + size, 0)
          if (normalizedTotal > 0) {
            Object.keys(newSizes).forEach((id) => {
              newSizes[id] = (newSizes[id] / normalizedTotal) * 100
            })
          }
          setSizes(newSizes)
        }
      }
    }, [panels.length])

    return (
      <ResizableContext.Provider
        value={{
          direction,
          panels,
          sizes,
          setSizes,
          registerPanel,
          unregisterPanel,
        }}
      >
        <div
          ref={ref}
          className={cn(
            "flex",
            direction === "horizontal" ? "flex-row" : "flex-col",
            className
          )}
          {...props}
        >
          {children}
        </div>
      </ResizableContext.Provider>
    )
  }
)
ResizablePanelGroup.displayName = "ResizablePanelGroup"

interface ResizablePanelProps extends React.HTMLAttributes<HTMLDivElement> {
  defaultSize?: number
  minSize?: number
  maxSize?: number
  id?: string
}

const ResizablePanel = React.forwardRef<HTMLDivElement, ResizablePanelProps>(
  ({ className, defaultSize, minSize = 10, maxSize = 90, id, children, ...props }, ref) => {
    const { registerPanel, unregisterPanel, sizes, direction } = useResizable()
    const panelId = id || React.useId()
    const panelRef = React.useRef<HTMLDivElement>(null)

    React.useImperativeHandle(ref, () => panelRef.current as HTMLDivElement)

    React.useEffect(() => {
      registerPanel(panelId, defaultSize)
      return () => {
        unregisterPanel(panelId)
      }
    }, [panelId, defaultSize, registerPanel, unregisterPanel])

    const size = sizes[panelId] || defaultSize || 50

    return (
      <div
        ref={panelRef}
        data-resizable-panel={panelId}
        className={cn(
          "relative overflow-hidden",
          direction === "horizontal" ? "h-full" : "w-full",
          className
        )}
        style={{
          [direction === "horizontal" ? "width" : "height"]: `${size}%`,
        }}
        {...props}
      >
        {children}
      </div>
    )
  }
)
ResizablePanel.displayName = "ResizablePanel"

interface ResizableHandleProps extends React.HTMLAttributes<HTMLDivElement> {
  withHandle?: boolean
  disabled?: boolean
}

const ResizableHandle = React.forwardRef<HTMLDivElement, ResizableHandleProps>(
  ({ className, withHandle = true, disabled, ...props }, ref) => {
    const { direction, panels, sizes, setSizes } = useResizable()
    const [isResizing, setIsResizing] = React.useState(false)
    const [startPos, setStartPos] = React.useState(0)
    const [startSizes, setStartSizes] = React.useState<Record<string, number>>({})
    const handleRef = React.useRef<HTMLDivElement>(null)

    React.useImperativeHandle(ref, () => handleRef.current as HTMLDivElement)

    const handleMouseDown = (e: React.MouseEvent) => {
      if (disabled) return
      e.preventDefault()
      setIsResizing(true)
      setStartPos(direction === "horizontal" ? e.clientX : e.clientY)
      setStartSizes({ ...sizes })
    }

    React.useEffect(() => {
      if (!isResizing) return

      const handleMouseMove = (e: MouseEvent) => {
        const currentPos = direction === "horizontal" ? e.clientX : e.clientY
        const delta = currentPos - startPos

        if (!handleRef.current) return

        const container = handleRef.current.parentElement
        if (!container) return

        const containerSize = direction === "horizontal"
          ? container.offsetWidth
          : container.offsetHeight

        const deltaPercent = (delta / containerSize) * 100

        const panelIndex = panels.findIndex((id) => {
          const panel = container.querySelector(`[data-resizable-panel="${id}"]`)
          return panel && (direction === "horizontal"
            ? panel.getBoundingClientRect().right <= handleRef.current!.getBoundingClientRect().left
            : panel.getBoundingClientRect().bottom <= handleRef.current!.getBoundingClientRect().top)
        })

        if (panelIndex >= 0 && panelIndex < panels.length - 1) {
          const leftPanelId = panels[panelIndex]
          const rightPanelId = panels[panelIndex + 1]

          const newSizes = { ...startSizes }
          const leftSize = newSizes[leftPanelId] || 50
          const rightSize = newSizes[rightPanelId] || 50

          const newLeftSize = Math.max(10, Math.min(90, leftSize + deltaPercent))
          const newRightSize = Math.max(10, Math.min(90, rightSize - deltaPercent))

          newSizes[leftPanelId] = newLeftSize
          newSizes[rightPanelId] = newRightSize

          setSizes(newSizes)
        }
      }

      const handleMouseUp = () => {
        setIsResizing(false)
      }

      document.addEventListener("mousemove", handleMouseMove)
      document.addEventListener("mouseup", handleMouseUp)

      return () => {
        document.removeEventListener("mousemove", handleMouseMove)
        document.removeEventListener("mouseup", handleMouseUp)
      }
    }, [isResizing, startPos, startSizes, direction, panels, sizes, setSizes])

    return (
      <div
        ref={handleRef}
        data-resizable-handle
        className={cn(
          "relative flex items-center justify-center bg-background",
          direction === "horizontal" ? "w-2 cursor-col-resize" : "h-2 cursor-row-resize",
          disabled && "cursor-default opacity-50",
          isResizing && "bg-primary/20",
          className
        )}
        onMouseDown={handleMouseDown}
        {...props}
      >
        {withHandle && (
          <div
            className={cn(
              "rounded-full bg-foreground",
              direction === "horizontal" ? "h-8 w-1" : "h-1 w-8"
            )}
          />
        )}
      </div>
    )
  }
)
ResizableHandle.displayName = "ResizableHandle"

export {
  ResizablePanelGroup,
  ResizablePanel,
  ResizableHandle,
}

JavaScript: Copy this code into components/ui/resizable.jsx:

jsx
"use client"

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

const ResizableContext = React.createContext(undefined)

const useResizable = () => {
  const context = React.useContext(ResizableContext)
  if (!context) {
    throw new Error("Resizable components must be used within ResizablePanelGroup")
  }
  return context
}

const ResizablePanelGroup = React.forwardRef(
  ({ className, direction = "horizontal", children, ...props }, ref) => {
    const [panels, setPanels] = React.useState([])
    const [sizes, setSizes] = React.useState({})

    const registerPanel = React.useCallback((id, defaultSize) => {
      setPanels((prev) => {
        if (!prev.includes(id)) {
          return [...prev, id]
        }
        return prev
      })
      if (defaultSize !== undefined) {
        setSizes((prev) => ({
          ...prev,
          [id]: defaultSize,
        }))
      }
    }, [])

    const unregisterPanel = React.useCallback((id) => {
      setPanels((prev) => prev.filter((p) => p !== id))
      setSizes((prev) => {
        const newSizes = { ...prev }
        delete newSizes[id]
        return newSizes
      })
    }, [])

    React.useEffect(() => {
      if (panels.length > 0) {
        const totalSize = panels.reduce((sum, id) => sum + (sizes[id] || 0), 0)
        if (totalSize === 0 || totalSize !== 100) {
          const defaultSize = 100 / panels.length
          const newSizes = {}
          panels.forEach((id) => {
            newSizes[id] = sizes[id] || defaultSize
          })
          const normalizedTotal = Object.values(newSizes).reduce((sum, size) => sum + size, 0)
          if (normalizedTotal > 0) {
            Object.keys(newSizes).forEach((id) => {
              newSizes[id] = (newSizes[id] / normalizedTotal) * 100
            })
          }
          setSizes(newSizes)
        }
      }
    }, [panels.length])

    return (
      <ResizableContext.Provider
        value={{
          direction,
          panels,
          sizes,
          setSizes,
          registerPanel,
          unregisterPanel,
        }}
      >
        <div
          ref={ref}
          className={cn(
            "flex",
            direction === "horizontal" ? "flex-row" : "flex-col",
            className
          )}
          {...props}
        >
          {children}
        </div>
      </ResizableContext.Provider>
    )
  }
)
ResizablePanelGroup.displayName = "ResizablePanelGroup"

const ResizablePanel = React.forwardRef(
  ({ className, defaultSize, minSize = 10, maxSize = 90, id, children, ...props }, ref) => {
    const { registerPanel, unregisterPanel, sizes, direction } = useResizable()
    const panelId = id || React.useId()
    const panelRef = React.useRef(null)

    React.useImperativeHandle(ref, () => panelRef.current)

    React.useEffect(() => {
      registerPanel(panelId, defaultSize)
      return () => {
        unregisterPanel(panelId)
      }
    }, [panelId, defaultSize, registerPanel, unregisterPanel])

    const size = sizes[panelId] || defaultSize || 50

    return (
      <div
        ref={panelRef}
        data-resizable-panel={panelId}
        className={cn(
          "relative overflow-hidden",
          direction === "horizontal" ? "h-full" : "w-full",
          className
        )}
        style={{
          [direction === "horizontal" ? "width" : "height"]: `${size}%`,
        }}
        {...props}
      >
        {children}
      </div>
    )
  }
)
ResizablePanel.displayName = "ResizablePanel"

const ResizableHandle = React.forwardRef(
  ({ className, withHandle = true, disabled, ...props }, ref) => {
    const { direction, panels, sizes, setSizes } = useResizable()
    const [isResizing, setIsResizing] = React.useState(false)
    const [startPos, setStartPos] = React.useState(0)
    const [startSizes, setStartSizes] = React.useState({})
    const handleRef = React.useRef(null)

    React.useImperativeHandle(ref, () => handleRef.current)

    const handleMouseDown = (e) => {
      if (disabled) return
      e.preventDefault()
      setIsResizing(true)
      setStartPos(direction === "horizontal" ? e.clientX : e.clientY)
      setStartSizes({ ...sizes })
    }

    React.useEffect(() => {
      if (!isResizing) return

      const handleMouseMove = (e) => {
        const currentPos = direction === "horizontal" ? e.clientX : e.clientY
        const delta = currentPos - startPos

        if (!handleRef.current) return

        const container = handleRef.current.parentElement
        if (!container) return

        const containerSize = direction === "horizontal"
          ? container.offsetWidth
          : container.offsetHeight

        const deltaPercent = (delta / containerSize) * 100

        const panelIndex = panels.findIndex((id) => {
          const panel = container.querySelector(`[data-resizable-panel="${id}"]`)
          return panel && (direction === "horizontal"
            ? panel.getBoundingClientRect().right <= handleRef.current.getBoundingClientRect().left
            : panel.getBoundingClientRect().bottom <= handleRef.current.getBoundingClientRect().top)
        })

        if (panelIndex >= 0 && panelIndex < panels.length - 1) {
          const leftPanelId = panels[panelIndex]
          const rightPanelId = panels[panelIndex + 1]

          const newSizes = { ...startSizes }
          const leftSize = newSizes[leftPanelId] || 50
          const rightSize = newSizes[rightPanelId] || 50

          const newLeftSize = Math.max(10, Math.min(90, leftSize + deltaPercent))
          const newRightSize = Math.max(10, Math.min(90, rightSize - deltaPercent))

          newSizes[leftPanelId] = newLeftSize
          newSizes[rightPanelId] = newRightSize

          setSizes(newSizes)
        }
      }

      const handleMouseUp = () => {
        setIsResizing(false)
      }

      document.addEventListener("mousemove", handleMouseMove)
      document.addEventListener("mouseup", handleMouseUp)

      return () => {
        document.removeEventListener("mousemove", handleMouseMove)
        document.removeEventListener("mouseup", handleMouseUp)
      }
    }, [isResizing, startPos, startSizes, direction, panels, sizes, setSizes])

    return (
      <div
        ref={handleRef}
        data-resizable-handle
        className={cn(
          "relative flex items-center justify-center bg-background",
          direction === "horizontal" ? "w-2 cursor-col-resize" : "h-2 cursor-row-resize",
          disabled && "cursor-default opacity-50",
          isResizing && "bg-primary/20",
          className
        )}
        onMouseDown={handleMouseDown}
        {...props}
      >
        {withHandle && (
          <div
            className={cn(
              "rounded-full bg-foreground",
              direction === "horizontal" ? "h-8 w-1" : "h-1 w-8"
            )}
          />
        )}
      </div>
    )
  }
)
ResizableHandle.displayName = "ResizableHandle"

export {
  ResizablePanelGroup,
  ResizablePanel,
  ResizableHandle,
}

Usage

TypeScript:

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

function MyComponent() {
  return (
    <ResizablePanelGroup direction="horizontal" className="h-[200px]">
      <ResizablePanel defaultSize={50}>
        <div className="flex h-full items-center justify-center p-6">
          <span className="font-semibold">One</span>
        </div>
      </ResizablePanel>
      <ResizableHandle />
      <ResizablePanel defaultSize={50}>
        <ResizablePanelGroup direction="vertical">
          <ResizablePanel defaultSize={50}>
            <div className="flex h-full items-center justify-center p-6">
              <span className="font-semibold">Two</span>
            </div>
          </ResizablePanel>
          <ResizableHandle />
          <ResizablePanel defaultSize={50}>
            <div className="flex h-full items-center justify-center p-6">
              <span className="font-semibold">Three</span>
            </div>
          </ResizablePanel>
        </ResizablePanelGroup>
      </ResizablePanel>
    </ResizablePanelGroup>
  )
}

Make sure you also have the lib/utils.ts file with the cn helper function.

Examples

Default

One
Two
Three

Vertical

Top
Middle
Bottom