New: Activity Terrain +12 more

Resizable

Panels you can drag apart, with a quiet divider that wakes up on hover, sizes that glide on reset or collapse, and layouts that persist.

7 examplesDepends oncnreact-resizable-panelsFrom HextaUI (MIT)

Installation

Run this in your project. It adds the files, installs the npm packages and anything this builds on, and sets up the theme:

npx shadcn@latest add https://kryptonite-ui.vercel.app/r/resizable.json
Install manually
  1. Install the dependencies:

    npm install cn react-resizable-panels
  2. Copy this into components/ui/resizable.tsx:

    resizable.tsx
    "use client"
    
    import * as React from "react"
    import { cn } from "cn"
    import * as ResizablePrimitive from "react-resizable-panels"
    
    function useMergedElementRef<T>(
      own: React.RefObject<T | null>,
      external: React.Ref<T | null> | undefined
    ) {
      return React.useCallback(
        (node: T | null) => {
          own.current = node
          if (typeof external === "function") {
            return external(node)
          }
          if (external) {
            external.current = node
          }
          return undefined
        },
        [own, external]
      )
    }
    
    function ResizablePanelGroup({
      className,
      elementRef,
      groupRef: groupHandleRef,
      defaultLayout,
      onPointerDownCapture,
      onKeyDownCapture,
      ...props
    }: ResizablePrimitive.GroupProps) {
      const groupRef = React.useRef<HTMLDivElement | null>(null)
      const setRef = useMergedElementRef(groupRef, elementRef)
      const handleRef =
        React.useRef<ResizablePrimitive.GroupImperativeHandle | null>(null)
      const setHandleRef = useMergedElementRef(handleRef, groupHandleRef)
      const appliedLayout = React.useRef(defaultLayout)
      const interacted = React.useRef(false)
    
      React.useLayoutEffect(() => {
        const previous = appliedLayout.current
        appliedLayout.current = defaultLayout
        if (
          !defaultLayout ||
          interacted.current ||
          JSON.stringify(defaultLayout) === JSON.stringify(previous)
        ) {
          return
        }
        const element = groupRef.current
        const ready = element?.hasAttribute("data-ready")
        element?.removeAttribute("data-ready")
        handleRef.current?.setLayout(defaultLayout)
        if (ready) {
          requestAnimationFrame(() => {
            requestAnimationFrame(() => element?.setAttribute("data-ready", ""))
          })
        }
      }, [defaultLayout])
    
      React.useEffect(() => {
        let second = 0
        const first = requestAnimationFrame(() => {
          second = requestAnimationFrame(() => {
            groupRef.current?.setAttribute("data-ready", "")
          })
        })
        return () => {
          cancelAnimationFrame(first)
          cancelAnimationFrame(second)
        }
      }, [])
    
      return (
        <ResizablePrimitive.Group
          data-slot="resizable-panel-group"
          elementRef={setRef}
          groupRef={setHandleRef}
          defaultLayout={defaultLayout}
          onPointerDownCapture={(event) => {
            onPointerDownCapture?.(event)
            interacted.current = true
          }}
          onKeyDownCapture={(event) => {
            onKeyDownCapture?.(event)
            interacted.current = true
          }}
          className={cn(
            "flex h-full w-full min-w-0 aria-[orientation=vertical]:flex-col motion-safe:data-ready:not-has-[>[data-separator=active]]:*:data-panel:transition-[flex-grow] motion-safe:data-ready:not-has-[>[data-separator=active]]:*:data-panel:duration-300 motion-safe:data-ready:not-has-[>[data-separator=active]]:*:data-panel:ease-out-quint",
            className
          )}
          {...props}
        />
      )
    }
    
    function ResizablePanel({
      className,
      ...props
    }: ResizablePrimitive.PanelProps) {
      return (
        <ResizablePrimitive.Panel
          data-slot="resizable-panel"
          className={cn("min-w-0", className)}
          {...props}
        />
      )
    }
    
    type ResizableSizeUnit = "percent" | "pixels"
    
    function useSizeReadout(
      separatorRef: React.RefObject<HTMLDivElement | null>,
      valueRef: React.RefObject<HTMLSpanElement | null>,
      unit: ResizableSizeUnit | null
    ) {
      React.useEffect(() => {
        const separator = separatorRef.current
        const value = valueRef.current
        if (!unit || !separator || !value) {
          return
        }
        let panel = separator.previousElementSibling
        while (panel && !panel.hasAttribute("data-panel")) {
          panel = panel.previousElementSibling
        }
    
        const update = () => {
          if (unit === "percent") {
            const now = Number(separator.getAttribute("aria-valuenow"))
            value.textContent = Number.isFinite(now) ? `${Math.round(now)}%` : ""
            return
          }
          if (!panel) {
            value.textContent = ""
            return
          }
          const rect = panel.getBoundingClientRect()
          const vertical =
            separator.getAttribute("aria-orientation") === "horizontal"
          value.textContent = `${Math.round(vertical ? rect.height : rect.width)}px`
        }
    
        update()
        const mutations = new MutationObserver(update)
        mutations.observe(separator, {
          attributes: true,
          attributeFilter: ["aria-valuenow"],
        })
        const resize =
          typeof ResizeObserver === "undefined" || !panel
            ? null
            : new ResizeObserver(update)
        if (panel) {
          resize?.observe(panel)
        }
        return () => {
          mutations.disconnect()
          resize?.disconnect()
        }
      }, [separatorRef, valueRef, unit])
    }
    
    type ResizableHandleProps = ResizablePrimitive.SeparatorProps & {
      withHandle?: boolean
      showSize?: boolean | ResizableSizeUnit
    }
    
    function ResizableHandle({
      withHandle = false,
      showSize = false,
      className,
      elementRef,
      children,
      ...props
    }: ResizableHandleProps) {
      const separatorRef = React.useRef<HTMLDivElement | null>(null)
      const valueRef = React.useRef<HTMLSpanElement | null>(null)
      const setRef = useMergedElementRef(separatorRef, elementRef)
      const unit = showSize === true ? "percent" : showSize || null
      useSizeReadout(separatorRef, valueRef, unit)
    
      return (
        <ResizablePrimitive.Separator
          aria-label="Resize panels"
          data-slot="resizable-handle"
          data-with-handle={withHandle ? "" : undefined}
          elementRef={setRef}
          className={cn(
            "group/resizable-handle relative z-1 flex w-px shrink-0 items-center justify-center bg-border outline-none [--resizable-line:1px] before:absolute before:inset-y-0 before:start-1/2 before:w-(--resizable-line) before:-translate-x-1/2 before:bg-transparent before:transition-[width,height,background-color] before:duration-150 before:ease-out-quint focus-visible:outline-hidden focus-visible:[--resizable-line:3px] focus-visible:before:bg-focus-ring aria-[orientation=horizontal]:h-px aria-[orientation=horizontal]:w-full aria-[orientation=horizontal]:before:inset-x-0 aria-[orientation=horizontal]:before:inset-y-auto aria-[orientation=horizontal]:before:start-0 aria-[orientation=horizontal]:before:top-1/2 aria-[orientation=horizontal]:before:h-(--resizable-line) aria-[orientation=horizontal]:before:w-auto aria-[orientation=horizontal]:before:translate-x-0 aria-[orientation=horizontal]:before:-translate-y-1/2 data-[separator=active]:[--resizable-line:3px] data-[separator=active]:before:bg-primary data-[separator=disabled]:cursor-default data-[separator=hover]:[--resizable-line:3px] data-[separator=hover]:before:bg-muted-foreground/40 motion-reduce:before:transition-none rtl:before:translate-x-1/2 forced-colors:bg-canvas-text",
            className
          )}
          {...props}
        >
          <span
            aria-hidden="true"
            data-slot="resizable-handle-grip"
            className="pointer-events-none relative h-8 w-1.5 shrink-0 scale-75 rounded-full bg-background opacity-0 inset-ring-(length:--hairline) inset-ring-border transition-[opacity,scale,box-shadow] duration-150 ease-out-quint group-focus-visible/resizable-handle:scale-100 group-focus-visible/resizable-handle:opacity-100 group-aria-[orientation=horizontal]/resizable-handle:h-1.5 group-aria-[orientation=horizontal]/resizable-handle:w-8 group-data-with-handle/resizable-handle:scale-100 group-data-with-handle/resizable-handle:opacity-100 group-data-[separator=active]/resizable-handle:scale-100 group-data-[separator=active]/resizable-handle:opacity-100 group-data-[separator=active]/resizable-handle:inset-ring-primary group-data-[separator=disabled]/resizable-handle:hidden group-data-[separator=hover]/resizable-handle:scale-100 group-data-[separator=hover]/resizable-handle:opacity-100 motion-reduce:transition-none dark:bg-muted forced-colors:border"
          />
          {unit ? (
            <span
              aria-hidden="true"
              data-slot="resizable-handle-size"
              className="pointer-events-none absolute start-1/2 top-1/2 translate-x-[-50%] -translate-y-[calc(50%+1.75rem)] scale-95 rounded-md bg-popover px-1.5 py-0.5 font-mono text-[0.6875rem] whitespace-nowrap text-popover-foreground tabular-nums opacity-0 shadow-sm ring-(length:--hairline) ring-foreground/10 transition-[opacity,scale] duration-150 ease-out-quint group-focus-visible/resizable-handle:scale-100 group-focus-visible/resizable-handle:opacity-100 group-aria-[orientation=horizontal]/resizable-handle:-translate-x-[calc(50%+3rem)] group-aria-[orientation=horizontal]/resizable-handle:-translate-y-1/2 group-data-[separator=active]/resizable-handle:scale-100 group-data-[separator=active]/resizable-handle:opacity-100 motion-reduce:transition-none rtl:translate-x-1/2 forced-colors:border"
            >
              <span ref={valueRef} />
            </span>
          ) : null}
          {children}
        </ResizablePrimitive.Separator>
      )
    }
    
    function subscribeNothing() {
      return () => {}
    }
    
    type UseDefaultLayoutOptions = Parameters<
      typeof ResizablePrimitive.useDefaultLayout
    >[0]
    
    function useDefaultLayout(options: UseDefaultLayoutOptions) {
      const hydrated = React.useSyncExternalStore(
        subscribeNothing,
        () => true,
        () => false
      )
      const custom = options.storage
      const storage = React.useMemo<ResizablePrimitive.LayoutStorage>(
        () =>
          custom ?? {
            getItem: (key) => {
              if (!hydrated) {
                return null
              }
              try {
                return window.localStorage.getItem(key)
              } catch {
                return null
              }
            },
            setItem: (key, value) => {
              if (!hydrated) {
                return
              }
              try {
                window.localStorage.setItem(key, value)
              } catch {}
            },
          },
        [custom, hydrated]
      )
      return ResizablePrimitive.useDefaultLayout({ ...options, storage })
    }
    
    const usePanelRef = ResizablePrimitive.usePanelRef
    const useGroupRef = ResizablePrimitive.useGroupRef
    
    export {
      ResizableHandle,
      ResizablePanel,
      ResizablePanelGroup,
      useDefaultLayout,
      useGroupRef,
      usePanelRef,
    }
    export type { ResizableHandleProps, ResizableSizeUnit }

Examples

Collapsible

Visible grip

Remember the layout

Right to left

Size readout

Vertical

Feedback

Questions, bugs or ideas for Resizable? Post them here, or reply to someone else. Sign in with GitHub to comment.