GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

CSS Resize Handle: 9 Demos That Follow Your Grip

A css resize handle left to the resize property alone cannot change its shape, ignores line height and just stops at the limit — scenes 09, 02 and 06 compute

Drag All Nine Resize Handles Right on This Page

Auto-plays · click a tile to jump to its section · all nine in one zip

CSS Resize Handle: 9 Demos Where Target Size, Detents and Anchors Are Computed

Every scene is a frame of up to 440 by 252 pixels with one handle in it (scene 05 has eight), and the cream circle is a fake pointer that grabs the handle and drags it as a demonstration. Grab the handle yourself and the demonstration steps aside, and the arrow keys change the size too.

All nine scenes share one idea. The pointer position is never used as the size directly; it only sets a target, and the value that gets drawn is computed separately on its way to that target. Scenes 01, 04 and 06 use a spring, 02, 03, 07 and 09 use an exponential approach, and 05 and 08 compute the value in place with no smoothing at all. No file has a single line of CSS transition or @keyframes, and none loads a library or an outside address.

If a css resizable sidebar is what you are after, open 04 first, and for css resizable table columns start at 03. The divider that splits two panels is the job of 9 Resizable Split Pane Layouts With No Library, while this page covers only the handle on the edge of a box, a column or an image.

Code for Each Technique Behind a CSS Resize Handle Style

Each snippet below is cut straight from the contiguous lines of a scene's index.html <script>, and none runs past 25 lines. Scene 07 does its work in CSS, so its snippet comes from style.scss.

01A Box That Follows the Corner Grip Like a Spring

The pointer only sets the target sizes tw and th; the drawn w and h chase them through spring, which holds a velocity, so a released handle overshoots once before it settles. That suits card and widget resizing, and since DAMPING is a ratio of 0.5, changing STIFFNESS from 170 leaves the overshoot proportion alone. It covers 78.1% cumulative area with 23/23 frames moving, the largest fill of the nine.

A Box That Follows the Corner Grip Like a Spring
Open in a new tab

01 · A Box That Follows the Corner Grip Like a Spring — Measured area 78.149% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

twvwSTIFFNESSDAMPING
    function spring(x, v, to, k, c, dt) {
        const n = Math.max(1, Math.ceil(dt / 0.008));
        const h = dt / n;
        for (let i = 0; i < n; i += 1) {
            v += (-k * (x - to) - c * v) * h;
            x += v * h;
        }
        return [x, v];
    }
    function step(dt, now) {
        init();
        if (demo)
            script(now);
        if (dragging) {
            tw = clamp(px + gx, MIN_W, root.clientWidth);
            th = clamp(py + gy, MIN_H, root.clientHeight);
        }
        const c = 2 * DAMPING * Math.sqrt(STIFFNESS);
        const a = spring(w, vw, tw, STIFFNESS, c, dt);
        const b = spring(h, vh, th, STIFFNESS, c, dt);
        w = a[0];
        vw = a[1];
        h = b[0];
        vh = b[1];
    }

02A Textarea Handle That Snaps to Line-Height Multiples

The dragged height rawH is divided by the line height LINE_H (28) and rounded to a whole row count, and the box eases toward the height of that many rows. It suits note and comment fields; the dotted line stays at the raw height of the hand so the gap to the box is visible, and the ROWS number beside it counts lines. Cumulative area is 50.6% with 23/23 frames moving.

A Textarea Handle That Snaps to Line-Height Multiples
Open in a new tab

02 · A Textarea Handle That Snaps to Line-Height Multiples — Measured area 50.593% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

LINE_HROUND_ROWSEASE_TOrows
    function rowsFor(height) {
        const r = Math.round((height - 2 * PAD) / LINE_H / ROUND_ROWS) * ROUND_ROWS;
        return clamp(r, 1, maxRows());
    }
    function step(dt, now) {
        init();
        if (demo)
            script(now);
        if (dragging) {
            rawH = clamp(py + gy, LINE_H + 2 * PAD, root.clientHeight - 12);
            rows = rowsFor(rawH);
        }
        h += (rows * LINE_H + 2 * PAD - h) * (1 - Math.exp(-EASE_TO * dt));
    }

03A Table Whose Neighbor Column Absorbs the Width You Drag

Dragging a column border shrinks the column on its right by the same amount, so the three widths always add up to the table width, and when the neighbor reaches COL_MIN (56px) the border stops while the pointer travels on. It suits data tables and file lists, and the truncation is the text-overflow: ellipsis rule in scss rather than JS, so the browser re-cuts the text the moment a width changes. Cumulative area is 11.6%, the smallest of the nine because only column widths change, with 23/23 frames moving.

A Table Whose Neighbor Column Absorbs the Width You Drag
Open in a new tab

03 · A Table Whose Neighbor Column Absorbs the Width You Drag — Measured area 11.608% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

COL_MINFOLLOWmoveBordertext-overflow
    function moveBorder(i, want) {
        const T = total();
        if (i === 0)
            t0w = clamp(want, COL_MIN, t1w - COL_MIN);
        else
            t1w = clamp(want, t0w + COL_MIN, T - COL_MIN);
    }
    function step(dt, now) {
        init();
        if (demo)
            script(now);
        else if (drag >= 0)
            moveBorder(drag, px + gx);
        const k = 1 - Math.exp(-FOLLOW * dt);
        d0 += (t0w - d0) * k;
        d1 += (t1w - d1) * k;
    }

04A Sidebar Edge That Clicks Into Three Detent Widths

While you drag, a width within 36px of a detent (56, 144 or 232 in DETENTS) is pulled toward it, harder the closer it gets, and on release the handle glides to the detent nearest raw + vel × 0.2, the spot 0.2 seconds ahead. The demonstration lets go at 170, but at about 305px/s the hand looks 61px ahead to 231, so the panel settles on 232 instead of the nearer 144, which is the behavior an admin sidebar wants. Cumulative area is 28.3% with 18/23 frames moving.

A Sidebar Edge That Clicks Into Three Detent Widths
Open in a new tab

04 · A Sidebar Edge That Clicks Into Three Detent Widths — Measured area 28.338% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password

DETENTSPULL_RADIUSFLING_VELnearest
    function pulled(x) {
        const d = DETENTS[nearest(x)];
        const dist = Math.abs(x - d);
        if (dist >= PULL_RADIUS)
            return x;
        const t = 1 - dist / PULL_RADIUS;
        return x + (d - x) * t * t * 0.9;
    }
    function release() {
        if (!dragging)
            return;
        dragging = false;
        target = Math.min(DETENTS[nearest(raw + vel * FLING_VEL)], limit());
    }

05An Image That Scales From Eight Handles With Ratio Locked

Whichever handle you hold, the opposite point stays put as the anchor, and a corner projects the pointer vector onto the (4/3, 1) diagonal so that one side length s sets both w and h. It suits image editors: the edge handles keep the ratio too by spreading from the midpoint of the anchor edge along the other axis, and while a corner is dragged a pink dotted line joins the pointer to the projected corner. Cumulative area is 50.4% with 21/23 frames moving.

An Image That Scales From Eight Handles With Ratio Locked
Open in a new tab

05 · An Image That Scales From Eight Handles With Ratio Locked — Measured area 50.427% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password

ASPECTMIN_SIDEresizeTomaxS
    function resizeTo(i, qx, qy) {
        const W = root.clientWidth;
        const H = root.clientHeight;
        const sx = hx(i);
        const sy = hy(i);
        const ax = sx > 0 ? x : sx < 0 ? x + w : x + w / 2;
        const ay = sy > 0 ? y : sy < 0 ? y + h : y + h / 2;
        if (sx !== 0 && sy !== 0) {
            const dx = (qx - ax) * sx;
            const dy = (qy - ay) * sy;
            const maxS = Math.min((sx > 0 ? W - ax : ax) / ASPECT, sy > 0 ? H - ay : ay);
            const s = clamp((dx * ASPECT + dy) / (ASPECT * ASPECT + 1), MIN_SIDE, maxS);
            w = s * ASPECT;
            h = s;
            x = sx > 0 ? ax : ax - w;
            y = sy > 0 ? ay : ay - h;
        }

06A Box That Resists Harder Past the Limit Then Springs Back

The limit is 0.64 times the container width; inside it the box follows the hand, past it the width becomes limit + 5 × √(excess) and grows heavier to drag, and on release a weak spring pulls it back to the limit line. That fits a panel with a hard ceiling, and in a node simulation the box passes the limit by about 31% of the stretch and settles in 0.68 seconds. Cumulative area is 55.1% with 22/23 frames moving.

A Box That Resists Harder Past the Limit Then Springs Back
Open in a new tab

06 · A Box That Resists Harder Past the Limit Then Springs Back — Measured area 55.13% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password

MAX_RATIORESISTSPRING_BACKstretch
    function stretch(r) {
        const m = maxW();
        return r <= m ? r : m + RESIST * Math.sqrt(r - m);
    }
    function step(dt, now) {
        init();
        if (demo)
            script(now);
        if (dragging) {
            if (!demo)
                raw = clamp(px + gx, MIN_W, root.clientWidth);
            target = clamp(stretch(raw), MIN_W, root.clientWidth);
            const a = spring(w, vw, target, 1600, 80, dt);
            w = a[0];
            vw = a[1];
        }
        else {
            const a = spring(w, vw, target, SPRING_BACK * SPRING_BACK, 2 * 0.35 * SPRING_BACK, dt);
            w = a[0];
            vw = a[1];
        }
    }

07A Card Whose Layout Changes Clothes As the Width Moves

JS drags a single box width, and two scss @container rules switch the layout of the card inside at 150px and 250px. It suits checking responsive components, and JS reads the same two boundaries only to print the range name STACK, ROW or GRID under the frame. Cumulative area is 65.5% with 23/23 frames moving.

A Card Whose Layout Changes Clothes As the Width Moves
Open in a new tab

07 · A Card Whose Layout Changes Clothes As the Width Moves — Measured area 65.505% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

BREAK_ABREAK_BRANGE_LABELcontainer-type
@container (min-width: 150px) {
  .rh__cards { grid-template-columns: repeat(3, 1fr); grid-auto-rows: auto; }
  .rh__card { flex-direction: column; align-items: flex-start; justify-content: flex-start; }
  .rh__card span { width: 100%; flex: none; }
}
@container (min-width: 250px) {
  .rh__cards { grid-template-columns: 1fr 1fr; grid-template-rows: 1.2fr 1fr; }
  .rh__card:first-child { grid-column: 1 / -1; flex-direction: row; align-items: center; }
  .rh__card:first-child i { width: 40px; height: 40px; }
  .rh__card span { flex: 1; width: auto; }
}

08A Handle Whose Guide Lines Flash When It Meets a Grid or Neighbor

When the nearest candidate, either a grid line or the left or right edge of a neighbor box, is closer than 12px, snapEdge pulls the value toward it, harder the closer it gets, and within 4.8px the guide line switches on. It suits layout editors, and once lit the guide fades with a time constant of 0.35 seconds. Cumulative area is 51.2% with 23/23 frames moving.

A Handle Whose Guide Lines Flash When It Meets a Grid or Neighbor
Open in a new tab

08 · A Handle Whose Guide Lines Flash When It Meets a Grid or Neighbor — Measured area 51.228% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

GRID_STEPSNAP_DISTGUIDE_FADEsnapEdge
    function snapEdge(r, cands) {
        let best = cands[0];
        for (let i = 1; i < cands.length; i += 1) {
            if (Math.abs(cands[i] - r) < Math.abs(best - r))
                best = cands[i];
        }
        const d = Math.abs(r - best);
        if (d >= SNAP_DIST)
            return [r, 0, best];
        return [best + (r - best) * (d / SNAP_DIST), d < SNAP_DIST * 0.4 ? 1 : 0, best];
    }
    function resolve(dt) {
        const gridX = Math.round(rawR / GRID_STEP) * GRID_STEP;
        const gridY = Math.round(rawB / GRID_STEP) * GRID_STEP;
        const sx = snapEdge(rawR, [gridX, nbLeft(), nbLeft() + nbWidth()]);
        const sy = snapEdge(rawB, [gridY, nbTop() + nbHeight()]);
        bw = Math.max(MIN_SIDE, sx[0] - GRID_STEP);
        bh = Math.max(MIN_SIDE, sy[0] - GRID_STEP);
        if (sx[1] === 1) {
            aV = 1;
            posV = sx[2];
        }
        else
            aV *= Math.exp(-dt / GUIDE_FADE);
        if (sy[1] === 1) {
            aH = 1;
            posH = sy[2];
        }
        else
            aH *= Math.exp(-dt / GUIDE_FADE);
    }

09Handle Shapes Whose Dots Spread on Approach and Sink When Grabbed

Six dots grow and push outward the closer they sit to the pointer within 56px, and once the handle is grabbed the pad sinks 3px and tilts with the horizontal speed. It suits handle design, since resizing itself stays the same and only the look of the grip answers the hand. Cumulative area is 42.5% with 22/23 frames moving.

Handle Shapes Whose Dots Spread on Approach and Sink When Grabbed
Open in a new tab

09 · Handle Shapes Whose Dots Spread on Approach and Sink When Grabbed — Measured area 42.515% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password

GRIP_DOTSHOVER_SPREADPRESS_DEPTHTILT_BY_VEL
        const ox = w - INSET - PAD;
        const oy = h - INSET - PAD;
        pad.style.setProperty("--p", press.toFixed(3));
        pad.style.transform = "translate(" + (press * PRESS_DEPTH).toFixed(2) + "px," + (press * PRESS_DEPTH).toFixed(2) + "px) rotate(" + tilt.toFixed(2) + "deg)";
        for (let i = 0; i < GRIP_DOTS; i += 1) {
            const bx = 40 - cols[i] * 14;
            const by = 40 - rowsOf[i] * 14;
            const dx = ox + bx - px;
            const dy = oy + by - py;
            const d = Math.max(1, Math.hypot(dx, dy));
            const f = Math.max(0, 1 - d / HOVER_SPREAD);
            const s = (1 + 1.2 * f * f) * (1 - 0.2 * press);
            const mx = (dx / d) * 6 * f;
            const my = (dy / d) * 6 * f;
            dots[i].style.transform = "translate(" + (bx + mx - 4).toFixed(2) + "px," + (by + my - 4).toFixed(2) + "px) scale(" + s.toFixed(3) + ")";
        }

Where the Values Change the Feel — Stiffness, Damping, Detent Radius, Grid Step

Three values change how the handle feels in the hand. The numbers here come from running the same calculation in a node simulation, so they are kept apart from the figures measured in the browser render.

Stiffness Up, Overshoot Unchanged

Raising STIFFNESS in 01 from 170 to 360 cuts the settling time from 0.61 to 0.41 seconds while the overshoot stays near 16%. Damping is set as a ratio instead of a coefficient, so a firmer feel needs DAMPING changed together with it.

A Wider Detent Radius Makes the Zones Overlap

The PULL_RADIUS of 36 in 04 gives a 72px pull zone and 64 gives 128px, but the detents sit only 88px apart, so at radius 64 each zone runs into its neighbor. At a width of 170 the pulled value moves from 168.2 to 161.8, closer to 144.

A Finer Grid Means More Guide Flashes

Shrinking GRID_STEP in 08 from 24 to 16 raises the horizontal lines crossed by the demonstration path from 10 to 16 and the vertical ones from 5 to 7. With more lines the guide lights up more often, and wherever the hand lands, a nearby line pulls on it.

The full-render measurements sit in the next table. The smallest cumulative area is 11.6% in scene 03, so none of the nine falls under 10%, and the frame counts below 23 are 04 (18), 05 (21), 06 (22) and 09 (22), whose cause was not investigated.

Scene Cumulative area Mean intensity Frames moving
01 Spring corner 78.1% 106.8 23/23
02 Line-height snap 50.6% 121.4 23/23
03 Table column border 11.6% 129.5 23/23
04 Sidebar detents 28.3% 159.8 18/23
05 Eight-way ratio lock 50.4% 83.5 21/23
06 Limit resistance 55.1% 128.2 22/23
07 Container reflow 65.5% 122.1 23/23
08 Grid guides 51.2% 93.0 23/23
09 Grip shape response 42.5% 155.6 22/23

Where It Breaks — Narrow Screens, Touch Input, Widths Below the Minimum

The Spring Width Spills Out of the Container

Scene 01 clamps the target tw to the container width, but the real w is the spring, so near the limit it travels about 16% outside the container and then comes back. Nothing clamps w, so in a spot where the outside is cut off, hide the spilled edge with overflow.

Table Columns Lose Their Letters at 56px

At 56px a column has only 32px of text room once its 12px side padding is taken out, so the MODIFIED header in 03 is cut short and ends in an ellipsis mark. The minimum width is only a number, so if the text going into a cell is longer, raise the minimum with it.

A Drag Perpendicular to the Diagonal Changes Nothing

A corner in 05 uses only the component along the diagonal, so a pointer moving at a right angle to it leaves s unchanged. The edge handles stop at 2 × min(ay, H - ay) × ASPECT, which means the closer the center line sits to a container edge the sooner they stop, and the E edge in the demonstration halts at a width of about 228.

The Pointer Passes the Limit but the Keyboard Cannot

In 06 a pointer can stretch past the limit, while the arrow keys stop at maxW(). Because the limit is 0.64 times the container width, resizing the window moves the limit line as well.

The Boundary Numbers Live in JS and in CSS

The 150 and 250 in 07 are written twice, once as BREAK_A and BREAK_B in JS and again in the two scss @container rules, so editing only one side leaves the range name under the frame disagreeing with the real layout.

Touch Has No Hover

The dots in 09 spread only on pointermove over the root, so a finger gets no spread before the grab. When the pointer leaves the root, pointerleave clears its position and the spread vanishes at once.

The Old Width Stays After the Window Shrinks

The first size is taken from the container size in the first frame times a ratio. When the window gets smaller, 09 clamps every frame and follows, 08 clamps only while you drag, and 03 and 04 keep the old pixel values until the next input arrives.

Edit the Constants and Watch the Boundary Move

Change STIFFNESS in 01, PULL_RADIUS in 04 and GRID_STEP in 08 one at a time, and each number above shows up at the place it changes. Open the archive below with the password mayqf5m8 to get the nine scene sources and the React version.

Three Variants and Accessibility

The three rows change one constant each, and the figures are from a node simulation of the same math.

Name Value Feel
Change the stiffness 01 STIFFNESS 170 → 360 Overshoot stays near 16% while the settling time falls from 0.61 to 0.41 seconds, so the box sticks closer to the hand.
Change the detent radius 04 PULL_RADIUS 36 → 64 The pull zone grows from 72px to 128px and a drag near width 170 is dragged harder toward the 144 detent.
Change the grid step 08 GRID_STEP 24 → 16 The same move now crosses 16 horizontal lines instead of 10 and 7 vertical lines instead of 5, so the guides flash far more often.

Each handle is a real <button>, so Tab reaches it and the arrow keys change the size. The preventDefault call fires only for the keys that a scene actually uses (four arrows in 01, 05, 08 and 09, up and down in 02, left and right in the rest), and all nine scenes set setPointerCapture and touch-action: none, so a drag continues after the pointer leaves the frame and a touch does not scroll the page along with it.

With prefers-reduced-motion turned on, every scene switches off the demonstration, never starts its animation loop and draws a single still image. Input then draws the target size at once without smoothing, and 06 does not pass its limit. One limit remains: no scene announces a new size through aria-live, so a screen reader will not read it, and a real page needs a separate region that states the numbers.

Is the resize Property Alone Enough for a CSS Resizable Box?

The resize property only chooses whether a user may stretch an element horizontally, vertically or both, so changing the grip's shape, handing width to a neighbor column or snapping to line height is not its job. Once a page needs any of that, the pointer events take over and the size is calculated by hand, as it is here.

Each scene has its own place to sit. 01 suits a dashboard card, 02 a comment box, 03 a file list table, 04 an admin sidebar, 05 an image editor, 06 a settings panel with a cap, 07 a responsive card review screen, 08 a layout editor and 09 a grip design mockup. The drag input that moves an edge works on the same principle as Drag and Drop Animation CSS: 9 Physics Patterns, and the feel of a dragged value catching on a detent continues in CSS Range Slider: 9 Physics-Grade Custom Sliders. When the drop target is a list position, see Drag to Reorder UI: 9 Lists, One Drop Index, and for how the pointer behaves at the lowest level read the Pointer Events guide.

Frequently Asked Questions Before Placing a CSS Resize Handle

Which scene is a css custom resize handle that keeps an image ratio?

Open 05. The opposite point is the anchor, a corner sets the size from one projected side, and an edge sets it along a single axis, so the ratio always stays 4 to 3. For another ratio, change only ASPECT.

How far does one arrow-key press move each handle?

It depends on the scene. 01, 06, 07 and 09 move 16px (06 stops at its limit, maxW()), 03 moves 12px, 05 pushes the handle dot 8px, 02 moves one line, 04 moves to the neighboring detent and 08 moves one grid step (24px).

Do I need a library or images?

No. None of the nine files makes an outside request, and they run on HTML, CSS and JS alone, so a single scene file can be moved by itself and still work.

Enter the archive password

The password is inside this article. You will find it as you read.