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.
Controlled size of the first pane; pair with onSizeChange.
onSizeChange
(size: number) => void
—
Called as the divider moves.
min
number
20
Smallest first-pane size, in percent.
max
number
80
Largest first-pane size, in percent.
step
number
2
Arrow-key step, in percent.
snapPoints
number[]
—
Sizes the divider snaps to while dragging; Page Up and Page Down jump between them.
snapThreshold
number
3
How close, in percent, a drag must come to a snap point to land on it.
showReadouts
boolean
true
Show live pixel readouts in the pane headers.
dividerLabel
string
"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>
);
}