GODRICH
#cssアニメーション#ボタン#ローディング#トランジション#スクロール#ダッシュボード#実用ui#デザイントレンド

css resize handle 9パターン — 角を引くと箱が手に吸い付く

css resize handle を resize プロパティだけに任せると、つまみの形は変えられず、行の高さに合わず、限界で突然止まる — 09番・02番・06番が計算で直す。ボックスや表の列、サイドバーの縁を引くと数字で追ってくる九つの場面を、圧縮ファイルごと貼れば動きます。

九つのサイズ変更つまみをこのページで引いてみる

自動再生 · タイルを押すとその項目へ · すべて1つのzip

css resize handle 9パターン — 目標サイズ・目盛り・固定点を計算して手に吸い付く

一つの場面には最大440×252の枠(min(100%, 440px)×252px)とつまみが一つ入っています(05だけは八つ)。クリーム色の丸は、偽のポインターがつまみをつかんで引くデモです。自分でつかむとデモは引き下がり、キーボードの矢印キーでも大きさが変わります。

九つの場面は同じ考え方で動きます。ポインターの位置をそのまま大きさにせず、目標としてだけ持ち、画面に描く値は別にその目標へ向かって計算します。近づけ方は01・04・06がバネ、02・03・07・09が指数による近づき、05・08は補間なしでその場で値を出します。九つのファイルには CSS の transition も @keyframes も一行もなく、ライブラリも外部アドレスへのリクエストもありません。

css resize horizontal だけが欲しいなら03の列の境界から、サイドバーの幅を決めたいなら04から開いてかまいません。パネルの間を分ける仕切り線は 画面分割 css レイアウト9選 — ドラッグで幅が変わる が受け持つので、この記事はボックス・列・画像の縁のつまみだけを扱います。

css resize つまみ デザインを技法別のコードで見る

下のコードは、各場面の index.html の <script> から連続した行をそのまま切り出したもので、一つは25行を超えません。07だけは計算が CSS 側にあるため style.scss を載せました。

01角のつまみを引くとバネのように追ってくる箱

ポインターは目標サイズの tw・th だけを決め、画面の w・h は spring が速度を持ったまま追うので、手を離すと一度行き過ぎてから落ち着きます。カードやウィジェットのサイズ変更に向き、減衰を比率0.5(DAMPING)で持つため STIFFNESS の170を変えても行き過ぎの割合は変わりません。累積面積は78.1%、動いたフレームは23/23で、九つの中で画面をいちばん広く使います。

角のつまみを引くとバネのように追ってくる箱
新しいタブで開く

01 · 角のつまみを引くとバネのように追ってくる箱 — 計測 累積面積 78.149% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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];
    }

02行の高さの倍数に吸い付くテキストエリアのつまみ

引いた高さ rawH を行の高さ LINE_H(28)で割って最も近い行数に丸め、箱はその行数の高さまで指数で近づきます。メモやコメント欄に向き、点線は手のある高さのまま動くので箱とのずれが見え、横の ROWS の数字が行数を数えます。累積面積は50.6%、動いたフレームは23/23です。

行の高さの倍数に吸い付くテキストエリアのつまみ
新しいタブで開く

02 · 行の高さの倍数に吸い付くテキストエリアのつまみ — 計測 累積面積 50.593% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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));
    }

03境界線を引くと隣の列が残りの幅を受け取る表

列の境界を引くとその分だけ右の列が縮み、三つの列の幅の合計はいつも表の幅と同じで、隣が COL_MIN(56px)に届くと境界が止まってポインターだけが先へ進みます。データ表や一覧に向き、省略記号は JS ではなく scss の text-overflow: ellipsis が出すので、幅が変わるとブラウザがすぐ切り直します。累積面積は11.6%で九つの中でいちばん小さく、動くのが列の幅だけだからです(動いたフレームは23/23)。

境界線を引くと隣の列が残りの幅を受け取る表
新しいタブで開く

03 · 境界線を引くと隣の列が残りの幅を受け取る表 — 計測 累積面積 11.608% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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;
    }

04三段階の幅にカチッと止まるサイドバーの縁

引いた幅が目盛り56・144・232(DETENTS)の36px以内に入ると近いほど強く目盛りへ引き寄せられ、離すと raw + vel × 0.2 で0.2秒先を見込んだ位置にいちばん近い目盛りへ流れます。デモは170で離しますが、手の速度が約305px/sなので61px先の231を見て、近い144ではなく232へ行きます。管理画面のサイドバーに向き、累積面積は28.3%、動いたフレームは18/23です。

三段階の幅にカチッと止まるサイドバーの縁
新しいタブで開く

04 · 三段階の幅にカチッと止まるサイドバーの縁 — 計測 累積面積 28.338% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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());
    }

05比率を守って八方向に伸びる画像

どのつまみをつかんでも反対側の点が固定点として残り、角ではポインターのベクトルを対角線(4/3、1)の上へ投影して得た一辺 s ひとつから w・h を決めます。画像エディターに向き、辺のつまみは比率だけを守ったまま、固定する辺の中点から反対の軸へ広がります。角を引いている間はピンクの点線がポインターと投影された頂点を結び、累積面積は50.4%、動いたフレームは21/23です。

比率を守って八方向に伸びる画像
新しいタブで開く

05 · 比率を守って八方向に伸びる画像 — 計測 累積面積 50.427% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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;
        }

06限界を超えると抵抗が増して戻ってくる箱

限界はコンテナ幅の0.64倍で、その内側では手にそのまま従い、超えると m + RESIST * Math.sqrt(r - m) で少しずつ重くなり、離すと弱いバネが限界線へ戻します。限界のあるパネルに向き、同じ計算をノードで回すと、最初に伸びた量の約31%だけ限界を行き過ぎて0.68秒で落ち着きます。累積面積は55.1%、動いたフレームは22/23です。

限界を超えると抵抗が増して戻ってくる箱
新しいタブで開く

06 · 限界を超えると抵抗が増して戻ってくる箱 — 計測 累積面積 55.13% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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];
        }
    }

07幅が変わるたびに配置が着替えるカード

箱の幅は JS が一つだけ引き、中のカードの並びは scss の @container の規則二つが150pxと250pxで切り替えます。レスポンシブ部品の確認に向き、JS は同じ境界で区間名の STACK・ROW・GRID を読むだけで、画面の下に書き出します。累積面積は65.5%、動いたフレームは23/23です。

幅が変わるたびに配置が着替えるカード
新しいタブで開く

07 · 幅が変わるたびに配置が着替えるカード — 計測 累積面積 65.505% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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; }
}

08格子や隣の箱に合うとガイド線が光るつまみ

いちばん近い候補(格子線と隣の箱の左右の縁)までの距離が12pxより短いと、snapEdge が値を候補のほうへ、近いほど強く引き、4.8px以内ではガイド線を灯します。レイアウトエディターに向き、ガイド線は灯った後に時定数0.35秒で薄れていきます。累積面積は51.2%、動いたフレームは23/23です。

格子や隣の箱に合うとガイド線が光るつまみ
新しいタブで開く

08 · 格子や隣の箱に合うとガイド線が光るつまみ — 計測 累積面積 51.228% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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);
    }

09近づくと点が広がり、つかむと沈むつまみの形

六つの点が、ポインターまでの距離56px以内で近いほど大きくなって外へ押され、つかむとパッドが3px沈み、横の速度に応じて傾きます。つまみのデザインに向き、サイズ変更の動作はそのままで、形だけが手の位置に反応します。累積面積は42.5%、動いたフレームは22/23です。

近づくと点が広がり、つかむと沈むつまみの形
新しいタブで開く

09 · 近づくと点が広がり、つかむと沈むつまみの形 — 計測 累積面積 42.515% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

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) + ")";
        }

値が手応えを変えるところ — 強さ・減衰・目盛りの半径・格子の間隔

触り心地が一つの値で変わる場所は三つあります。以下の数字は同じ計算をノードで回して数えたもので、ブラウザの描画で測った数字とは区別してください。

強さを上げても行き過ぎは変わらない

01の STIFFNESS を170から360へ上げると、落ち着くまでの時間は0.61秒から0.41秒に縮みますが、行き過ぎは約16%のままです。減衰を係数ではなく比率で持たせたためで、もっと硬い感触が欲しいときは DAMPING も一緒に動かします。

目盛りの半径を広げると区間が重なる

04の PULL_RADIUS が36なら引く区間は72px、64なら128pxですが、目盛りの間は88pxしかないので、64では隣の目盛りの区間と重なります。幅170で引き出された値は168.2から161.8になり、144のほうへさらに寄ります。

格子の間隔を狭めると案内線が増える

08の GRID_STEP を24から16へ狭めると、同じデモの動きで横を通る線が10本から16本、縦が5本から7本に増えます。線が増えるほどガイド線は頻繁に灯り、手をどこに置いても近い線の影響を受けます。

画面を実際に描画して測った結果は次の表です。10%を下回った場面はなく、いちばん小さいのは03の11.6%でした。動いたフレームが23に届かないのは04(18)・05(21)・06(22)・09(22)の四つで、その原因は確かめていません。

項目 累積面積 平均強度 動いたフレーム
01 バネの角 78.1% 106.8 23/23
02 行の高さへの吸着 50.6% 121.4 23/23
03 表の列の境界 11.6% 129.5 23/23
04 サイドバーの目盛り 28.3% 159.8 18/23
05 比率固定の八方向 50.4% 83.5 21/23
06 限界の抵抗 55.1% 128.2 22/23
07 コンテナでの組み替え 65.5% 122.1 23/23
08 格子のガイド線 51.2% 93.0 23/23
09 つまみの形の反応 42.5% 155.6 22/23

どこで壊れるか — 狭い画面・タッチ入力・最小幅より下のとき

バネの幅がコンテナの外へはみ出す

01は目標の tw をコンテナ幅で切りますが、実際の w はバネなので、限界の近くでは約16%だけ外へ出てから戻ってきます。w には clamp がないため、外側が切れる場所に置くなら、はみ出した瞬間の縁を overflow で隠す必要があります。

表の列が56pxで文字を失う

03は56pxになると、枠の余白を引いた32pxほどの幅に MODIFIED の見出しが収まらず、省略記号で終わります。最小幅はただの数字なので、収める文字が長いなら最小幅も一緒に上げなければなりません。

対角線と直角に引くと大きさが変わらない

05の角は対角線方向の成分だけを使うため、ポインターが対角線と直角に動くと s は変わりません。辺のつまみは 2 × min(ay, H - ay) × ASPECT が上限なので、中心線がコンテナの縁に近いほど早く止まり、デモのE辺は幅約228で止まります。

ポインターは限界を超えるがキーボードは超えない

06はポインターなら限界を超えて伸ばせますが、矢印キーは maxW() で止まります。限界はコンテナ幅の0.64倍なので、ウィンドウの大きさが変わると限界線も一緒に動きます。

境界の数字が JS と CSS に別々にある

07の150と250は、JS の BREAK_A・BREAK_B と scss の @container 二つにそれぞれ書かれています。片方だけ直すと、画面の下の区間名が実際の配置とずれます。

タッチにはホバーがない

09の点は root の上で起きる pointermove のときだけ広がるので、指では、つかむまで点が広がりません。ポインターが root を出ると pointerleave が位置を片づけ、広がりもすぐに消えます。

ウィンドウが縮んだあとに古い幅が残る

初期のサイズは、最初のフレームのコンテナの大きさに割合を掛けて決まります。ウィンドウが縮むと、毎フレーム切りながら追うのは09だけです。08はドラッグ中だけ切り、03・04は次の入力が来るまで古いピクセル値が残ります。

値を書き換えて境界を確かめる

01の STIFFNESS、04の PULL_RADIUS、08の GRID_STEP を一つずつ変えると、上の数字がどこで切り替わるかを手で確かめられます。ファイルは次の圧縮ファイルをパスワード mayqf5m8 で開けば手に入り、九つの場面のソースと React 版が入っています。

三つの変形とアクセシビリティ — キーボードと動きを減らす設定

名前 値 感触
強さを変える 01 STIFFNESS 170 → 360 行き過ぎの約16%は変わらず、落ち着く時間だけが0.61秒から0.41秒に縮んで手に寄り添う。
目盛りの半径を変える 04 PULL_RADIUS 36 → 64 引く区間が72pxから128pxに伸び、幅170の近くでは目盛りの144のほうへ強く引かれる。
格子の間隔を変える 08 GRID_STEP 24 → 16 同じ動きで合う横線が10本から16本、縦線が5本から7本に増え、ガイド線がずっと頻繁に光る。

三つの変形の数字は、ノードのシミュレーションで測った値です。

つまみは本物の <button> なので Tab で焦点が移り、矢印キーで大きさが変わります。preventDefault を呼ぶのは、その場面が使う矢印キーのときだけで、01・05・08・09は四方向、02は上下、03・04・06・07は左右です。九つの場面すべてに setPointerCapture と touch-action: none があり、つまみをつかんだまま枠の外へ出ても引きが続き、タッチでページが一緒に動くこともありません。

prefers-reduced-motion が有効だと、九つの場面すべてがデモを止め、アニメーションのループも始めずに一枚だけ描きます。入力は補間せず目標の大きさをその場で描き、06は限界を超えません。限界もあります。大きさが変わっても aria-live で知らせる場面は九つのうち一つもなく、スクリーンリーダーは新しい大きさを読み上げないので、実際の画面に使うときは数字を知らせる領域を別に付けてください。

css resizable は resize プロパティ一つで足りるか?

resize プロパティは、要素を横・縦・両方向に伸ばせるかどうかを選ぶだけの値です。つまみの形を変えたり、隣の列へ幅を分けたり、行の高さに合わせたりすることは受け持ちません。css resize both で足りなくなったら、この記事のようにポインターイベントを受けて大きさを自分で計算することになります。

場面ごとに使いどころは違います。01はダッシュボードのカード、02はコメント入力欄、03はファイル一覧の表、04は管理画面のサイドバー、05は画像エディター、06は上限のある設定パネル、07はレスポンシブなカードの確認画面、08はレイアウトエディター、09はつまみのデザイン案に向きます。縁を引く入力の仕組みは ドラッグ アンド ドロップ 実装 9パターン — 指を離した後まで続く計算 と同じで、引いた値が目盛りに吸い付く感触は css レンジスライダー — 慣性・スナップ計算を重ねたカスタム9種 に続きます。置く場所がリストの順番なら ドラッグアンドドロップ 並び替えリスト — 挿入先インデックス計算を重ねた9種 を見てください。ポインターイベントの基本は Pointer events に、07が使う区間の規則は コンテナクエリ にまとまっています。

css resize つまみ 位置を決める前によくある質問

css resize horizontal のように一方向だけでなく、画像の比率を守って伸ばすにはどの場面を見ればよいですか?

05です。固定点を反対側の点に置き、角は対角線への投影で出した一辺、辺のつまみは横か縦の一軸で大きさを決めるので、比率はいつも4対3のままです。別の比率が必要なら ASPECT だけを変えます。

矢印キーは一回でどれだけ動きますか?

場面によって違います。01・06・07・09は16px(06は maxW() で止まります)、03は12px、05は8pxで、02は一行、04は隣の目盛り、08は格子一マス(24px)ずつです。

ライブラリや画像は必要ですか?

必要ありません。九つのファイルには外部アドレスへのリクエストがなく、HTML・CSS・JS だけで動くので、場面のファイル一つを移すだけでも動きます。

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。