Skip to content
Pebble Pro is here at $49 one timeSee what’s included ↗
Pebble
ComponentsDocsMCPPricing
⌕ Find a component↗Log in
All components 673

Categories

Core inputs & forms46Buttons & actions33Navigation36Overlays & feedback49Data display33Charts & data viz40Cards & layout43Marketing blocks38App shells & pages37AI interfaces40Text & typography effects33Backgrounds & visual effects38Motion & transitions32Numbers & counters32Media34E-commerce333D & interactive36Themed sets40
Start with the essentials.Installation guide ↗Connect your agent ↗
← All componentsCards & layout

Resizable

Two panes with a draggable divider and live width readouts; the slim grip grows on hover, with optional snap points and horizontal or vertical layout.

Preview

Original50%

Rivers

Why the old weir is coming down

Mara Lindqvist · 6 min read

For ninety years the stone weir at Hollin Bridge held the river in a slow, brown pool. It was built to feed a mill that closed before most of the village was born.

This spring the trust will take it apart, block by block. Fish that have not reached the upper valley in living memory should be spawning there within two seasons.

Not everyone is pleased. The pool is where children learned to swim, and the sound of the water over the lip is, for some, the sound of home.

Revised50%

Rivers

The weir comes down, and the river returns

Mara Lindqvist · 5 min read

The stone weir at Hollin Bridge has held the river still for ninety years, long after the mill it fed went quiet.

Work starts in April. Ecologists expect trout and salmon to reach the upper valley within two seasons, for the first time in living memory.

The swimming pool most villagers grew up with will go too. A new shallow bay downstream is meant to take its place.

Drag the divider; it snaps at thirds and halfway. Double-click to reset.

notes.md55%
## Pebble notes: week 44

The north meadow finally drained after Tuesday's rain.
We counted **31** lapwings on the lower pond, up from 12.

- Rebuild the stile by the alder gate
- Order two more trail cameras
- Ask the parish about the footpath sign
Preview45%

Pebble notes: week 44

The north meadow finally drained after Tuesday's rain. We counted 31 lapwings on the lower pond, up from 12.

  • Rebuild the stile by the alder gate
  • Order two more trail cameras
  • Ask the parish about the footpath sign

Controlled split: editor 55%, preview 45%.

Inbox

Readouts hidden.

Message

Headers keep the labels.

Install

Add the editable source with the shadcn CLI. Your project also needs the shared theme tokens this component uses. Setup and requirements. The Pebble installer is not published yet.

npx shadcn@latest add https://pebbleui.com/r/resizable.json

Props

PropTypeDefaultDescription
panes[ResizablePane, ResizablePane]—The two panes: label and content.
orientation"horizontal" | "vertical""horizontal"Side by side or stacked.
defaultSizenumber50Starting size of the first pane, in percent.
sizenumber—Controlled size of the first pane; pair with onSizeChange.
onSizeChange(size: number) => void—Called as the divider moves.
minnumber20Smallest first-pane size, in percent.
maxnumber80Largest first-pane size, in percent.
stepnumber2Arrow-key step, in percent.
snapPointsnumber[]—Sizes the divider snaps to while dragging; Page Up and Page Down jump between them.
snapThresholdnumber3How close, in percent, a drag must come to a snap point to land on it.
showReadoutsbooleantrueShow live pixel readouts in the pane headers.
dividerLabelstring"Resize {start} and {end}"Accessible name of the divider.
Build notes
No motion library: the panes are flex children with `flex-basis: 0` and
`flex-grow` set to `size` and `100 - size`, so the split is exact regardless
of the divider's width, and a `transition-[flex-grow]` (off while dragging,
so the pane tracks the pointer 1:1) animates keyboard and reset moves. The
divider is a 12px hit area pulled into the gap with negative margins
(`-mx-1.5` / `-my-1.5`), so the visible seam is the 1px hairline centred in
it.

Pointer drag: `pointerdown` focuses the divider, captures the pointer and
sets `dragging`; `pointermove` maps the pointer's offset from the root's
`getBoundingClientRect()` to percent and passes it through `resolveSize`
(clamp to min/max, then pull onto the nearest snap point within the
threshold, rounded to 0.1). `touch-none` on the divider keeps touch drags
from scrolling.

Pixel readouts come from a `ResizeObserver` on both panes, set up in an
effect, so the server render and first client render show percent only and
agree; the px part appears after mount. Controlled mode calls
`onSizeChange` and renders `size`; uncontrolled keeps it in state.

**Pebble (2026-11-03).** The container is a raised card (`rounded-xl
bg-card ring-1 ring-hairline shadow-raised-md inset-shadow-highlight`) with
`border-border` header rules. The grip is a small raised pebble
(`shadow-raised-xs inset-shadow-highlight`) that grows with `ease-enter` over
`--motion-base`; while dragging it takes the lit-key gradient
(`from-key-top to-key-bottom`, `ring-key-edge`). Ember `accent` marks only
live things: the hairline while dragging and the snap guide currently hit.
Readouts are `font-mono tabular-nums` in `muted-foreground`, brightening to
`foreground` while dragging.

<!-- PEBBLE-LICENSE -->
View source

registry/layout/resizable/resizable.tsx

"use client";

/* PEBBLE-LICENSE
Built by Pebble. Component: resizable.

First-party license: MIT

The following first-party terms apply only to original Pebble material.

MIT License

Copyright (c) 2026 Praxys Solutions LLC (Pebble)

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
END PEBBLE-LICENSE */

import * as React from "react";

import { cn } from "@/lib/utils";

export type ResizablePane = {
  /** Short name, shown in the pane header and used in the divider's name. */
  label: string;
  /** Pane content. */
  content: React.ReactNode;
  /** Extra classes for the pane's scroll area. */
  className?: string;
};

export type ResizableProps = Omit<React.ComponentProps<"div">, "children"> & {
  /** The two panes, start (left or top) first. */
  panes: [ResizablePane, ResizablePane];
  /** `horizontal` puts panes side by side; `vertical` stacks them. */
  orientation?: "horizontal" | "vertical";
  /** Start pane size in percent when uncontrolled; also the double-click reset. */
  defaultSize?: number;
  /** Start pane size in percent (controlled). */
  size?: number;
  /** Called with the new start pane size in percent. */
  onSizeChange?: (size: number) => void;
  /** Smallest start pane size in percent. */
  min?: number;
  /** Largest start pane size in percent. */
  max?: number;
  /** Percent moved by one arrow key press. */
  step?: number;
  /** Sizes in percent the divider snaps to while dragging; Page Up/Down jump between them. */
  snapPoints?: number[];
  /** How close, in percent, a drag must come to a snap point to land on it. */
  snapThreshold?: number;
  /** Show live size readouts in the pane headers. */
  showReadouts?: boolean;
  /** Accessible name of the divider. Defaults to "Resize {start} and {end}". */
  dividerLabel?: string;
};

const round = (n: number) => Math.round(n * 10) / 10;

/** Clamps `value` to [min, max] and pulls it onto a snap point within `threshold`. */
export function resolveSize(
  value: number,
  min: number,
  max: number,
  snapPoints: number[] = [],
  threshold = 0,
) {
  let next = Math.min(max, Math.max(min, value));
  let best = threshold;
  for (const point of snapPoints) {
    const distance = Math.abs(point - next);
    if (distance <= best && point >= min && point <= max) {
      best = distance;
      next = point;
    }
  }
  return round(next);
}

function GripDots({ vertical }: { vertical: boolean }) {
  return (
    <span
      aria-hidden="true"
      className={cn(
        "flex gap-[3px] opacity-0 transition-opacity duration-(--motion-fast) group-hover/divider:opacity-100 group-focus-visible/divider:opacity-100 group-data-[dragging]/divider:opacity-100",
        vertical ? "flex-row" : "flex-col",
      )}
    >
      {[0, 1, 2].map((i) => (
        <span key={i} className="size-[3px] rounded-full bg-current" />
      ))}
    </span>
  );
}

export function Resizable({
  panes,
  orientation = "horizontal",
  defaultSize = 50,
  size: sizeProp,
  onSizeChange,
  min = 20,
  max = 80,
  step = 2,
  snapPoints,
  snapThreshold = 3,
  showReadouts = true,
  dividerLabel,
  className,
  ...props
}: ResizableProps) {
  const vertical = orientation === "vertical";
  const lo = Math.min(min, max);
  const hi = Math.max(min, max);
  const [internal, setInternal] = React.useState(() => resolveSize(defaultSize, lo, hi));
  const size = resolveSize(sizeProp ?? internal, lo, hi);
  const [dragging, setDragging] = React.useState(false);
  const [extent, setExtent] = React.useState<[number, number] | null>(null);
  const rootRef = React.useRef<HTMLDivElement>(null);
  const paneRefs = [React.useRef<HTMLDivElement>(null), React.useRef<HTMLDivElement>(null)];
  const uid = React.useId();
  const ids = [`${uid}-start`, `${uid}-end`];
  const [start, end] = panes;

  const commit = (next: number) => {
    const value = round(next);
    if (value === size) return;
    if (sizeProp === undefined) setInternal(value);
    onSizeChange?.(value);
  };

  // Live pixel readouts. Measured after mount, so the server render and the
  // first client render agree (percent only).
  React.useEffect(() => {
    if (!showReadouts || typeof ResizeObserver === "undefined") return;
    const [a, b] = [paneRefs[0].current, paneRefs[1].current];
    if (!a || !b) return;
    const measure = () => {
      const pick = (el: HTMLElement) => Math.round(vertical ? el.offsetHeight : el.offsetWidth);
      setExtent([pick(a), pick(b)]);
    };
    const observer = new ResizeObserver(measure);
    observer.observe(a);
    observer.observe(b);
    return () => observer.disconnect();
    // eslint-disable-next-line react-hooks/exhaustive-deps -- refs are stable
  }, [showReadouts, vertical]);

  const fromPointer = (event: React.PointerEvent) => {
    const rect = rootRef.current?.getBoundingClientRect();
    if (!rect) return size;
    const length = vertical ? rect.height : rect.width;
    if (!length) return size;
    const offset = vertical ? event.clientY - rect.top : event.clientX - rect.left;
    return resolveSize((offset / length) * 100, lo, hi, snapPoints, snapThreshold);
  };

  const sortedSnaps = React.useMemo(
    () => [...(snapPoints ?? [])].filter((p) => p >= lo && p <= hi).sort((a, b) => a - b),
    [snapPoints, lo, hi],
  );

  const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
    const pageNext = () => sortedSnaps.find((p) => p > size + 0.05) ?? hi;
    const pagePrev = () => [...sortedSnaps].reverse().find((p) => p < size - 0.05) ?? lo;
    const actions: Record<string, () => number> = {
      ArrowLeft: () => size - step,
      ArrowUp: () => size - step,
      ArrowRight: () => size + step,
      ArrowDown: () => size + step,
      PageUp: pagePrev,
      PageDown: pageNext,
      Home: () => lo,
      End: () => hi,
      Enter: () => defaultSize,
    };
    const action = actions[event.key];
    if (!action) return;
    event.preventDefault();
    commit(resolveSize(action(), lo, hi));
  };

  const readout = (i: 0 | 1) => {
    const percent = Math.round(i === 0 ? size : 100 - size);
    return (
      <span
        data-slot="resizable-readout"
        className={cn(
          "ml-auto font-mono text-[11px] text-muted-foreground tabular-nums transition-colors duration-(--motion-fast)",
          dragging && "text-foreground",
        )}
      >
        {percent}%{extent && <span className="text-muted-foreground"> · {extent[i]}px</span>}
      </span>
    );
  };

  const pane = (p: ResizablePane, i: 0 | 1) => (
    <div
      ref={paneRefs[i]}
      id={ids[i]}
      data-slot="resizable-pane"
      role="group"
      aria-label={p.label}
      style={{ flexGrow: i === 0 ? size : 100 - size }}
      className={cn(
        "relative flex min-h-0 min-w-0 basis-0 flex-col overflow-hidden",
        !dragging &&
          "transition-[flex-grow] duration-(--motion-base) ease-move motion-reduce:transition-none",
      )}
    >
      <div
        data-slot="resizable-header"
        className="flex h-8 shrink-0 items-center gap-2 border-b border-border px-3"
      >
        <span className="truncate text-xs font-medium text-foreground">{p.label}</span>
        {showReadouts && readout(i)}
      </div>
      {/* A scrolling region must be reachable by keyboard. */}
      <div
        tabIndex={0}
        role="region"
        aria-label={`${p.label} content`}
        className={cn(
          "min-h-0 flex-1 overflow-auto outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
          p.className,
        )}
      >
        {p.content}
      </div>
    </div>
  );

  return (
    <div
      ref={rootRef}
      data-slot="resizable"
      data-orientation={orientation}
      className={cn(
        "relative flex w-full overflow-hidden rounded-xl bg-card text-card-foreground",
        "shadow-raised-md ring-1 inset-shadow-highlight ring-hairline",
        "forced-colors:border forced-colors:border-[CanvasText]",
        vertical ? "h-96 flex-col" : "h-80 flex-row",
        dragging && (vertical ? "cursor-row-resize select-none" : "cursor-col-resize select-none"),
        className,
      )}
      {...props}
    >
      {pane(start, 0)}
      <div
        role="separator"
        tabIndex={0}
        aria-orientation={vertical ? "horizontal" : "vertical"}
        aria-valuenow={Math.round(size)}
        aria-valuemin={Math.round(lo)}
        aria-valuemax={Math.round(hi)}
        aria-valuetext={`${start.label} ${Math.round(size)}%, ${end.label} ${Math.round(100 - size)}%`}
        aria-controls={ids[0]}
        aria-label={dividerLabel ?? `Resize ${start.label} and ${end.label}`}
        data-slot="resizable-divider"
        data-dragging={dragging || undefined}
        onKeyDown={onKeyDown}
        onDoubleClick={() => commit(resolveSize(defaultSize, lo, hi))}
        onPointerDown={(event) => {
          if (event.button !== 0) return;
          event.preventDefault();
          event.currentTarget.focus();
          event.currentTarget.setPointerCapture?.(event.pointerId);
          setDragging(true);
        }}
        onPointerMove={(event) => {
          if (!dragging) return;
          commit(fromPointer(event));
        }}
        onPointerUp={(event) => {
          event.currentTarget.releasePointerCapture?.(event.pointerId);
          setDragging(false);
        }}
        onPointerCancel={() => setDragging(false)}
        className={cn(
          "group/divider relative z-10 flex shrink-0 touch-none items-center justify-center outline-none",
          vertical
            ? "-my-1.5 h-3 w-full cursor-row-resize"
            : "-mx-1.5 h-full w-3 cursor-col-resize",
        )}
      >
        {/* The hairline between the panes. */}
        <span
          aria-hidden="true"
          data-slot="resizable-hairline"
          className={cn(
            "absolute bg-border transition-colors duration-(--motion-fast)",
            "group-focus-visible/divider:bg-ring group-data-[dragging]/divider:bg-accent",
            // Forced colors: `!` so the (0,1,0) rest rule is not beaten by the
            // (0,2,0) state rules above; focus and drag light up in Highlight.
            "forced-colors:bg-[CanvasText]! forced-colors:group-focus-visible/divider:bg-[Highlight]! forced-colors:group-data-[dragging]/divider:bg-[Highlight]!",
            vertical ? "inset-x-0 top-1/2 h-px" : "inset-y-0 left-1/2 w-px",
          )}
        />
        {/* The grip: slim at rest, grows on hover, lit while dragging. */}
        <span
          aria-hidden="true"
          data-slot="resizable-grip"
          className={cn(
            "relative flex items-center justify-center rounded-full bg-card text-muted-foreground",
            "shadow-raised-xs ring-1 inset-shadow-highlight ring-hairline",
            "forced-colors:border forced-colors:border-[CanvasText]",
            "transition-[width,height,background-color,color,box-shadow] duration-(--motion-base) ease-enter motion-reduce:transition-none",
            vertical
              ? "h-1.5 w-8 group-hover/divider:h-3.5 group-hover/divider:w-12 group-focus-visible/divider:h-3.5 group-focus-visible/divider:w-12 group-data-[dragging]/divider:h-3.5 group-data-[dragging]/divider:w-12"
              : "h-8 w-1.5 group-hover/divider:h-12 group-hover/divider:w-3.5 group-focus-visible/divider:h-12 group-focus-visible/divider:w-3.5 group-data-[dragging]/divider:h-12 group-data-[dragging]/divider:w-3.5",
            "group-focus-visible/divider:ring-2 group-focus-visible/divider:ring-ring",
            // Forced colors drop the box-shadow ring; draw a real outline instead.
            "forced-colors:group-focus-visible/divider:outline-2 forced-colors:group-focus-visible/divider:outline-offset-2 forced-colors:group-focus-visible/divider:outline-[Highlight] forced-colors:group-focus-visible/divider:outline-solid",
            "group-data-[dragging]/divider:bg-linear-to-b group-data-[dragging]/divider:from-key-top group-data-[dragging]/divider:to-key-bottom group-data-[dragging]/divider:text-primary-foreground group-data-[dragging]/divider:ring-key-edge",
          )}
        >
          <GripDots vertical={vertical} />
        </span>
      </div>
      {pane(end, 1)}

      {/* Snap guides across the whole component while dragging. */}
      {dragging &&
        sortedSnaps.map((point) => (
          <span
            key={point}
            aria-hidden="true"
            data-slot="resizable-snap"
            data-hit={Math.abs(point - size) < 0.05 || undefined}
            className={cn(
              "pointer-events-none absolute z-0 border-dashed transition-colors duration-(--motion-fast)",
              vertical
                ? "inset-x-0 border-t border-t-muted-foreground/30 data-[hit]:border-t-accent"
                : "inset-y-0 border-l border-l-muted-foreground/30 data-[hit]:border-l-accent",
            )}
            style={vertical ? { top: `${point}%` } : { left: `${point}%` }}
          />
        ))}
    </div>
  );
}
Pebble

Small details.
Better interfaces.

React components to explore, make your own, and build on.

Library

  • Components
  • Installation
  • Pricing

Resources

  • For agents
  • About
  • Support

Legal

  • License
  • Terms
  • Privacy
  • Final sale

Built by Pebble

A product of Praxys Solutions LLC ↗

Pebble Pro is live

$49 one time, lifetime updates ↗