All patterns

Split View Resize

Dragging the divider resizes both panes live, and releasing it settles the split on the nearest stop.

navigationpremiumminimalinteraction · finite · advanced · ~0.3s
Interactive · click to play
Variant

The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.

383 lines · react + motion only
import {
  useRef,
  useState,
  type KeyboardEvent as ReactKeyboardEvent,
  type PointerEvent as ReactPointerEvent,
} from "react";
import {
  animate,
  motion,
  useMotionValue,
  useReducedMotion,
  useTransform,
} from "motion/react";

/**
 * Vibary · Split View Resize
 *
 * The divider follows the pointer exactly while it is held — no easing
 * between finger and edge — and only when it is let go does the split
 * travel to the nearest sensible stop.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * Surfaces are mixed from the inherited text color, so both panes read
 * correctly on a light page and on a dark one.
 * Works with zero props; tune via `variant`, `defaultRatio`.
 * Drag the divider, or focus it and use the arrow keys.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type SplitViewResizeProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Starting split, as a fraction of the total width. */
  defaultRatio?: number;
  /** Accent for the divider while it is held. */
  accent?: string;
  /** Fires with the settled split once the drag ends. */
  onSettle?: (ratio: number) => void;
};

type VariantConfig = {
  /** Spring the split travels on when it is released. */
  spring: { type: "spring"; stiffness: number; damping: number };
  /** How much the grip grows while held, as a fraction. */
  gripGrow: number;
  /** Seconds for the divider's own hover and hold states. */
  gripFade: number;
};

// Quality rule: nothing eases while the pointer is down — a divider that
// lags its own handle feels broken rather than smooth, so the drag is a
// direct projection of the pointer. The spring exists only for the snap
// after release, and it sits at or above a 0.8 damping ratio: panes are
// full of text, and text that overshoots its column and comes back is the
// worst thing this pattern can do. Variants change the snap's pace and
// the grip's presence, never the settle.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Snaps almost immediately. For an IDE-style layout that is adjusted
  // constantly.
  subtle: {
    spring: { type: "spring", stiffness: 620, damping: 46 },
    gripGrow: 1.15,
    gripFade: 0.12,
  },
  // A short, visible glide to the stop. All-purpose.
  default: {
    spring: { type: "spring", stiffness: 460, damping: 38 },
    gripGrow: 1.35,
    gripFade: 0.16,
  },
  // A longer glide, so the snap is legible as a decision the interface
  // made — for a reading or review layout.
  playful: {
    spring: { type: "spring", stiffness: 340, damping: 33 },
    gripGrow: 1.6,
    gripFade: 0.2,
  },
};

const ACCENT = "#7C7CF0";

/** Where the split is allowed to rest. Sensible stops beat free-form
 *  resizing: they are the layouts the panes were actually designed for. */
const STOPS = [0.3, 0.44, 0.58] as const;
const MIN_RATIO = 0.26;
const MAX_RATIO = 0.64;

/** Theme-adaptive neutral: `currentColor` is the text color this component
 *  inherits — near-black on a light page, near-white on a dark one — so
 *  mixing it with `transparent` yields a surface, border or fill that is
 *  correctly toned in either theme. The accent stays literal. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

const clamp = (value: number) =>
  Math.min(MAX_RATIO, Math.max(MIN_RATIO, value));

const nearestStop = (value: number) =>
  STOPS.reduce((best, stop) =>
    Math.abs(stop - value) < Math.abs(best - value) ? stop : best
  );

const FILES = [
  ["Q3 revenue", "4.2 MB"],
  ["Churn by tier", "1.1 MB"],
  ["Seat usage", "820 KB"],
  ["Support load", "2.6 MB"],
  ["Renewals", "640 KB"],
] as const;

export default function SplitViewResize({
  variant = "default",
  defaultRatio = 0.44,
  accent = ACCENT,
  onSettle,
}: SplitViewResizeProps) {
  const containerRef = useRef<HTMLDivElement>(null);
  const [dragging, setDragging] = useState(false);
  // Mirrored for assistive tech only, and updated when the split settles
  // rather than every frame — announcing a value sixty times a second is
  // noise, and re-rendering to do it would drop frames.
  const [announced, setAnnounced] = useState(Math.round(defaultRatio * 100));
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  // One value drives the layout, the readout and the stop markers, so
  // they can never disagree about where the divider is.
  const ratio = useMotionValue(clamp(defaultRatio));
  const leftWidth = useTransform(ratio, (value) => `${value * 100}%`);
  const readout = useTransform(ratio, (value) => `${Math.round(value * 100)}%`);

  const settle = (value: number) => {
    const target = nearestStop(value);
    setAnnounced(Math.round(target * 100));
    onSettle?.(target);
    // Reduced motion: arrive at the stop. The snap is the information —
    // the glide was only ever the delivery.
    if (reduceMotion) {
      ratio.set(target);
      return;
    }
    animate(ratio, target, cfg.spring);
  };

  const onPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
    event.currentTarget.setPointerCapture(event.pointerId);
    setDragging(true);
  };

  const onPointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
    if (!dragging) return;
    const rect = containerRef.current?.getBoundingClientRect();
    if (!rect) return;
    // Written straight to the motion value: no React state per frame, and
    // no easing between the pointer and the edge it is holding.
    ratio.set(clamp((event.clientX - rect.left) / rect.width));
  };

  const endDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
    if (!dragging) return;
    event.currentTarget.releasePointerCapture(event.pointerId);
    setDragging(false);
    settle(ratio.get());
  };

  const onKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
    const current = ratio.get();
    let next = current;
    if (event.key === "ArrowLeft") next = clamp(current - 0.04);
    else if (event.key === "ArrowRight") next = clamp(current + 0.04);
    else if (event.key === "Home") next = STOPS[0];
    else if (event.key === "End") next = STOPS[STOPS.length - 1];
    else return;
    event.preventDefault();
    // The keyboard moves in steps and then settles, so it lands on the
    // same stops the pointer does.
    settle(next);
  };

  return (
    <div
      style={{
        width: 372,
        borderRadius: 16,
        background: tone(6),
        color: "inherit",
        border: `1px solid ${tone(12)}`,
        boxShadow: "0 14px 34px rgba(0,0,0,0.18)",
        overflow: "hidden",
      }}
    >
      <div
        style={{
          display: "flex",
          alignItems: "center",
          justifyContent: "space-between",
          padding: "11px 14px",
          borderBottom: `1px solid ${tone(10)}`,
        }}
      >
        <span style={{ fontSize: 13, fontWeight: 650 }}>Reports workspace</span>
        <span
          style={{
            fontSize: 11,
            opacity: 0.5,
            fontVariantNumeric: "tabular-nums",
          }}
        >
          {/* A motion value rendered as text: the readout tracks the drag
              frame by frame without React re-rendering the tree. */}
          <motion.span>{readout}</motion.span>
        </span>
      </div>

      <div ref={containerRef} style={{ display: "flex", height: 234 }}>
        <motion.div
          style={{
            width: leftWidth,
            flexShrink: 0,
            padding: "10px 0 10px 12px",
            overflow: "hidden",
          }}
        >
          <div
            style={{
              fontSize: 10.5,
              letterSpacing: "0.06em",
              opacity: 0.45,
              marginBottom: 6,
            }}
          >
            FILES
          </div>
          {FILES.map(([name, size], index) => (
            <div
              key={name}
              style={{
                display: "flex",
                alignItems: "center",
                justifyContent: "space-between",
                gap: 8,
                padding: "7px 9px",
                marginBottom: 4,
                borderRadius: 8,
                background: index === 0 ? tone(10) : "transparent",
                border: `1px solid ${index === 0 ? tone(12) : "transparent"}`,
              }}
            >
              <span
                style={{
                  fontSize: 11.5,
                  fontWeight: 600,
                  whiteSpace: "nowrap",
                  overflow: "hidden",
                  textOverflow: "ellipsis",
                }}
              >
                {name}
              </span>
              <span style={{ fontSize: 10.5, opacity: 0.45, flexShrink: 0 }}>
                {size}
              </span>
            </div>
          ))}
        </motion.div>

        {/* The divider is positioned inside this panel, so the pattern
            drops into a card unchanged. In an app-level layout the same
            code works against the window: the ratio is always measured
            from the container's own bounding box, never from page
            coordinates. */}
        <div
          role="separator"
          aria-orientation="vertical"
          aria-label="Resize panes"
          aria-valuenow={announced}
          aria-valuemin={Math.round(MIN_RATIO * 100)}
          aria-valuemax={Math.round(MAX_RATIO * 100)}
          tabIndex={0}
          onPointerDown={onPointerDown}
          onPointerMove={onPointerMove}
          onPointerUp={endDrag}
          onPointerCancel={endDrag}
          onKeyDown={onKeyDown}
          style={{
            position: "relative",
            width: 13,
            flexShrink: 0,
            display: "grid",
            placeItems: "center",
            cursor: "col-resize",
            // The pointer owns this element for the whole gesture, so the
            // browser must not start a scroll or a text selection with it.
            touchAction: "none",
          }}
        >
          <motion.span
            aria-hidden
            initial={false}
            animate={{ opacity: dragging ? 1 : 0.35 }}
            transition={{ duration: cfg.gripFade, ease: "easeOut" }}
            style={{
              position: "absolute",
              top: 0,
              bottom: 0,
              width: 1,
              background: dragging ? accent : tone(22),
            }}
          />
          <motion.span
            aria-hidden
            initial={false}
            animate={{
              scaleY: dragging && !reduceMotion ? cfg.gripGrow : 1,
              opacity: dragging ? 1 : 0.55,
            }}
            transition={{ duration: cfg.gripFade, ease: "easeOut" }}
            style={{
              position: "relative",
              width: 3,
              height: 26,
              borderRadius: 2,
              background: dragging ? accent : tone(28),
            }}
          />
        </div>

        <div style={{ flex: 1, minWidth: 0, padding: "10px 12px 10px 0" }}>
          <div
            style={{
              height: "100%",
              padding: "11px 12px",
              borderRadius: 12,
              background: tone(7),
              border: `1px solid ${tone(10)}`,
              overflow: "hidden",
            }}
          >
            <div style={{ fontSize: 12.5, fontWeight: 650 }}>Q3 revenue</div>
            <div style={{ fontSize: 10.5, opacity: 0.5, marginTop: 2 }}>
              Updated 12 min ago · Priya Raman
            </div>
            <div
              aria-hidden
              style={{
                display: "flex",
                alignItems: "flex-end",
                gap: 5,
                height: 62,
                marginTop: 12,
              }}
            >
              {[34, 52, 41, 68, 58, 76].map((height, index) => (
                <span
                  key={index}
                  style={{
                    flex: 1,
                    height: `${height}%`,
                    borderRadius: 3,
                    background: index === 5 ? accent : tone(16),
                  }}
                />
              ))}
            </div>
            <div
              style={{
                marginTop: 12,
                paddingTop: 9,
                borderTop: `1px solid ${tone(10)}`,
                fontSize: 10.5,
                opacity: 0.5,
                lineHeight: 1.5,
              }}
            >
              Drag the divider between the panes. Let go and the split
              settles on the nearest stop.
            </div>
          </div>
        </div>
      </div>
    </div>
  );
}

About this pattern

Two panes and the rule between them, where the whole quality question is what eases and what does not. While the pointer is down nothing eases at all: the split is a direct projection of the pointer, written to a motion value so the edge never lags the hand holding it and React never re-renders mid-gesture. The spring appears only at release, taking the split to the nearest of a few sensible stops — the layouts the panes were actually designed for — and it is damped hard, because both panes are full of text and text that overshoots its column and comes back is the worst thing this pattern can do. The readout is the same value rendered as text, so the number and the layout cannot disagree, and the keyboard steps land on the same stops the pointer does.

Editor and preview layoutFile list with detail paneInbox and reading paneConsole with output panel

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • Ridgeline
    Docs
    Recent
    Shared
    Templates
    Trash
    DocsNew
    Q3 planning notesEdited 14 minutes agoScope
    Sidebar navigation

    A gutter between editor and side panel that resizes live and settles when released.

Related patterns