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

css popover 9パターン — アンカーから開き端で反転するポップオーバー

css popover は画面の端で切れ、矢印が違う方を指し、複数が重なる — 01番は空きの計算、04番は矢印の補間、09番は反発で解く。九つの場面を画面で開き、コードと測定値まで確かめられます。

九つのポップオーバーを画面でそのまま開いてみる

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

css popover 9パターン — アンカー周りの空き・ばね・ベクトルを計算して所定の位置に出す

ポップオーバーとは、ボタンや点のようなアンカーから開く小さな板のことです。HTML には開閉の基本動作をブラウザーに任せられる popover 属性がありますが、この編の九つの場面はその属性も CSS の anchor positioning も使いません。どれも <div> を一つ、JS が計算した translate3d で動かしているだけです。どちらに出て、どれだけ押され、重なったらどう避けるかを、目で追えるようにするための作りです。

場面一つの舞台は、幅が最大 440px、高さ 252px の半透明の板で、幅 320px の画面では約 294×174 に縮みます。この板の四辺が「画面の端」の役をしていて、本物のビューポートではありません。描画の取り込みは 480×300 なので、舞台の大きさと取り込みの大きさを混ぜて読まないでください。動きは 1/60 秒の固定ティックで進むため、画面のリフレッシュレートが違っても同じ形になります。

追いかけ方は場面ごとに違います。速度の変数を持つばねは 02・07・09 で、01・03・04・05・06 は毎ティック一定の割合で目標へ寄る指数的な接近、08 は直線的に進む値にイージングを重ねています。目標の位置を求める側も、01 は空きの点数、03 は三角形の判定、08 はノイズで揺らした多角形、09 は重なりの判定とばらばらです。九つのファイルのどれにもライブラリや外部リクエスト、Math.random、CSS の @keyframes はありません。

吹き出しが画面の端で切れる悩みなら 01、ドロップダウンのメニューを作るなら 03 と 07、ドラッグで動かすカードなら 05 から開くのが近道です。同じサイトの CSS ツールチップ 吹き出しアニメーション9選 一行でコピペOK、コンテキストメニュー UI 9選 — クリック座標の値でメニューが決まる仕組み、CSS ドロップダウン メニュー アニメーション 9選 — JS最小・CSSだけ も隣の話題です。

css popover positioning を技法別のコードで見る

ポップオーバーの位置を決める関数を、ソースから途切れずに続く行のまま場面ごとに一つ載せました。一つの長さは25行以内で、定数の宣言は断片から外して値を本文に書いたので、全体は zip の中のファイルで確かめてください。

01四方向の空きを測って位置を選ぶ衝突反転

choose() がアンカーの四辺それぞれの空きから、ポップオーバーの大きさと間隔 GAP の 12px を引いた点をつけ、今の側が0を下回るか、最高点より24を超えて低いときだけ向きを変えます。画面の端にあるヘルプの吹き出しに向き、反転するときはポップオーバーがアンカーの上を横切って滑ります。01 を描画して測ると、累積面積は52.1%、動いたフレームは23/23でした。

四方向の空きを測って位置を選ぶ衝突反転
新しいタブで開く

01 · 四方向の空きを測って位置を選ぶ衝突反転 — 計測 累積面積 52.147% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

GAPEDGE_PADARROW_SIZEFLIP_BIAS
    function choose(w, h, ax, ay) {
        free[0] = ay - ANCHOR_R - EDGE_PAD;
        free[1] = w - ax - ANCHOR_R - EDGE_PAD;
        free[2] = h - ay - ANCHOR_R - EDGE_PAD;
        free[3] = ax - ANCHOR_R - EDGE_PAD;
        const score = [0, 1, 2, 3].map((s) => free[s] - ((s % 2 === 0 ? POP_H : POP_W) + GAP));
        let best = 0;
        for (let s = 1; s < 4; s += 1) {
            if (score[s] > score[best])
                best = s;
        }
        if (score[side] < 0 || score[side] < score[best] - FLIP_BIAS)
            side = best;
    }

02アンカーの点からばねで膨らんで開くポップオーバー

ティックごとに、速度 vel へ目標との差に STIFFNESS を掛けた分を足し、DAMPING を掛けて弱めて大きさを解くので、目標の大きさ(scale 1)を少し行き過ぎてから戻ります。変形の基準点をアンカーの中心に置いてあるため、アンカーから生えたように見え、ボタンの横のクイック設定パネルに向きます。02 は累積面積が29.8%、動いたフレームは16/23でした。

アンカーの点からばねで膨らんで開くポップオーバー
新しいタブで開く

02 · アンカーの点からばねで膨らんで開くポップオーバー — 計測 累積面積 29.803% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

STIFFNESSDAMPINGORIGIN_XORIGIN_Y
    function tick(ms) {
        if (demo) {
            const cycle = Math.floor(ms / CYCLE_MS);
            const phase = ms - cycle * CYCLE_MS;
            want = phase >= 250 && phase < 1350;
            slot = (cycle + (phase >= 1900 ? 1 : 0)) % SLOTS.length;
        }
        nx += (SLOTS[slot] - nx) * 0.2;
        vel = (vel + ((want ? 1 : 0) - size) * STIFFNESS) * DAMPING;
        size += vel;
        if (size < 0) {
            size = 0;
            vel = 0;
        }
        if (want)
            peak = Math.max(peak, size);
        else if (size < 0.02)
            peak = 0;
    }

03ポインターの向きで閉じるのを遅らせる安全三角形

6ティック前のポインターの位置から、開いているパネルの左の二つの角へ三角形を引き、SPEED_MIN 以上の速さで右向きにその中を通っている間は、ほかの項目に触れても INTENT_MS の260msが過ぎるまで切り替えません。ホバーで開くサブメニューに向きます。03 の測定値は、累積面積が34.0%、動いたフレームが18/23です。

ポインターの向きで閉じるのを遅らせる安全三角形
新しいタブで開く

03 · ポインターの向きで閉じるのを遅らせる安全三角形 — 計測 累積面積 33.965% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

INTENT_MSTRIANGLE_PADSPEED_MINGRACE
    function side(ax, ay, bx, by, px, py) {
        return (px - bx) * (ay - by) - (ax - bx) * (py - by);
    }
    function inTriangle(px, py, a, b, c) {
        const d1 = side(px, py, a[0], a[1], b[0], b[1]);
        const d2 = side(px, py, b[0], b[1], c[0], c[1]);
        const d3 = side(px, py, c[0], c[1], a[0], a[1]);
        const neg = d1 < 0 || d2 < 0 || d3 < 0;
        const pos = d1 > 0 || d2 > 0 || d3 > 0;
        return !(neg && pos);
    }
    function corners() {
        const t = panelTop(Math.max(0, openIdx));
        return [[PANEL_X, t - TRIANGLE_PAD], [PANEL_X, t + PANEL_H + TRIANGLE_PAD]];
    }

04本体がずれてもアンカーを指す矢印

本体は端の余白 EDGE_PAD で止められてずれますが、矢印の先はアンカーの x を毎ティック ARROW_LERP の0.15ずつ追うので、本体が最大72px押されてもしっぽはアンカーの方を指します。幅の狭い画面の入力ガイドに向きます。04 を測ると、累積面積36.3%、動いたフレーム23/23という値でした。

本体がずれてもアンカーを指す矢印
新しいタブで開く

04 · 本体がずれてもアンカーを指す矢印 — 計測 累積面積 36.272% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

ARROW_LERPEDGE_PADARROW_SIZESHIFT_MAX
    function axOf(w) {
        return clamp(nx * w, EDGE_PAD + BODY_W / 2 - SHIFT_MAX, w - EDGE_PAD - BODY_W / 2 + SHIFT_MAX);
    }
    function bodyTarget(w, ax) {
        const desired = ax - BODY_W / 2;
        const clamped = clamp(desired, EDGE_PAD, w - EDGE_PAD - BODY_W);
        shift = clamp(clamped - desired, -SHIFT_MAX, SHIFT_MAX);
        return desired + shift;
    }
    function tick(ms) {
        if (demo)
            nx = 0.5 + 0.47 * Math.sin((Math.max(0, ms - 200) / SWEEP_MS) * Math.PI * 2);
        const w = root.clientWidth;
        const ax = axOf(w);
        if (!placed)
            snap();
        bodyX += (bodyTarget(w, ax) - bodyX) * BODY_LERP;
        tip += (ax - tip) * ARROW_LERP;
    }

05ドラッグで慣性でついてきて端でゴムのように粘るポップオーバー

アンカーをドラッグするとポップオーバーが毎ティック0.14の割合で後を追い、許される四角形を越えた距離は rubber() が28pxに近づくように縮めるので、ゴムのように引かれます。動かせるメモカードに向きます。05 の累積面積は54.5%で、動いたフレームは23/23です。

ドラッグで慣性でついてきて端でゴムのように粘るポップオーバー
新しいタブで開く

05 · ドラッグで慣性でついてきて端でゴムのように粘るポップオーバー — 計測 累積面積 54.481% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

FOLLOW_LERPRUBBEREDGE_PADLAG
    function rubber(over) {
        const o = Math.abs(over);
        const r = LAG * (1 - 1 / (1 + (o * RUBBER) / LAG));
        return over < 0 ? -r : r;
    }
    function resist(v, lo, hi) {
        if (v < lo)
            return lo + rubber(v - lo);
        if (v > hi)
            return hi + rubber(v - hi);
        return v;
    }
    function goal() {
        const w = root.clientWidth;
        const h = root.clientHeight;
        const tx = nx * w - POP_W / 2;
        const ty = ny * h - ANCHOR_R - LIFT - POP_H;
        const lx = EDGE_PAD;
        const hx = w - EDGE_PAD - POP_W;
        const ly = EDGE_PAD;
        const hy = h - EDGE_PAD - POP_H;
        overX = tx < lx ? tx - lx : tx > hx ? tx - hx : 0;
        overY = ty < ly ? ty - ly : ty > hy ? ty - hy : 0;
        return [resist(tx, lx, hx), resist(ty, ly, hy)];
    }

06深さが一段増えるごとに影と大きさが階段状に変わる入れ子ポップオーバー

深さ d が一段増えるたびに大きさが0.07、明るさが0.1ずつ下がり、影は7pxずつ深くなります。設定の中の設定メニューに向き、一段目の大きさ1.00・影7px・明るさ1.00が、三段目では0.86・21px・0.80まで下がります。06 の測定は、累積面積23.1%に対し動いたフレームが16/23でした。

深さが一段増えるごとに影と大きさが階段状に変わる入れ子ポップオーバー
新しいタブで開く

06 · 深さが一段増えるごとに影と大きさが階段状に変わる入れ子ポップオーバー — 計測 累積面積 23.144% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

DEPTH_STEPSHADOW_LIFTSCALE_STEPSTAGGER
        for (let i = 0; i < 3; i += 1) {
            const d = i + 1;
            const o = openAmt[i];
            const sc = (1 - (d - 1) * SCALE_STEP) * (0.92 + 0.08 * o);
            const lift = d * SHADOW_LIFT * o;
            const x = X0 + i * stepX - (1 - o) * 14;
            const y = Y0 + i * STEP_Y - (1 - o) * 8;
            lv[i].style.transform = "translate3d(" + x.toFixed(1) + "px," + y.toFixed(1) + "px,0) scale(" + sc.toFixed(3) + ")";
            lv[i].style.opacity = o.toFixed(2);
            lv[i].style.visibility = o > 0.02 ? "visible" : "hidden";
            lv[i].style.filter = "brightness(" + (1 - (d - 1) * DEPTH_STEP).toFixed(2) + ")";
            lv[i].style.boxShadow = "0 " + lift.toFixed(1) + "px " + (lift * 2).toFixed(1) + "px rgba(23, 20, 26, .34)";
        }

07メニュー項目の間をばねで滑る強調ピル

ピルの y と h がそれぞれ速度を持つばね(PILL_STIFF が0.18、PILL_DAMP が0.72)で、指した項目の位置と高さを追い、項目が変わると420msのあいだ光の筋が一度なぞります。アカウントのメニューに向きます。07 は累積面積18.3%と小さい一方で、平均強度は126.1、動いたフレームは19/23でした。

メニュー項目の間をばねで滑る強調ピル
新しいタブで開く

07 · メニュー項目の間をばねで滑る強調ピル — 計測 累積面積 18.318% · 動いたフレーム 19/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

PILL_STIFFPILL_DAMPSHEEN_WITEM_H
    function goalY() {
        return its[want].offsetTop;
    }
    function goalH() {
        return its[want].offsetHeight || ITEM_H;
    }
    function tick(ms) {
        if (demo)
            want = SEQ[Math.floor(Math.max(0, ms - 200) / STEP_MS) % SEQ.length];
        if (!placed)
            snap();
        if (want !== prevWant) {
            prevWant = want;
            sheenT = 0;
        }
        vy = (vy + (goalY() - py) * PILL_STIFF) * PILL_DAMP;
        py += vy;
        vh = (vh + (goalH() - ph) * PILL_STIFF) * PILL_DAMP;
        ph += vh;
        sheenT = Math.min(SHEEN_MS, sheenT + 1000 / 60);
    }

08アンカーから広がるノイズ縁の円形マスク

開くとき、アンカーの点から広がる clip-path: polygon() の半径を、角度ごとに二次元の値ノイズで基準の0.76〜1.24倍に揺らすので、インクが滲むように不規則な縁で内容が現れます。画像プレビューのポップアップに向きます。08 の累積面積は33.9%で、動いたフレームは14/23と九つの中でいちばん少なくなりました。

アンカーから広がるノイズ縁の円形マスク
新しいタブで開く

08 · アンカーから広がるノイズ縁の円形マスク — 計測 累積面積 33.885% · 動いたフレーム 14/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

BLOOM_MSNOISE_AMPNOISE_FREQRING
    function noise2(x, y) {
        const ix = Math.floor(x);
        const iy = Math.floor(y);
        const fx = x - ix;
        const fy = y - iy;
        const ux = fx * fx * (3 - 2 * fx);
        const uy = fy * fy * (3 - 2 * fy);
        const a = hash(ix, iy);
        const b = hash(ix + 1, iy);
        const c = hash(ix, iy + 1);
        const d = hash(ix + 1, iy + 1);
        return a + (b - a) * ux + (c - a) * uy + (a - b - c + d) * ux * uy;
    }
    function mask(r, extra) {
        const pts = [];
        for (let i = 0; i < POINTS; i += 1) {
            const a = (i / POINTS) * Math.PI * 2;
            const n = noise2(Math.cos(a) * NOISE_FREQ + 7 + p * 0.8, Math.sin(a) * NOISE_FREQ + 3);
            const rr = Math.max(0, r * (1 + NOISE_AMP * n) + extra);
            pts.push((ox + Math.cos(a) * rr).toFixed(1) + "px " + (oy + Math.sin(a) * rr).toFixed(1) + "px");
        }
        return "polygon(" + pts.join(",") + ")";
    }

09一つのアンカーの周りで押し合って落ち着くポップオーバーの輪

一つのアンカーを囲む楕円に、チップを角度で分けて置き、PULL の加速で引き寄せます。四角形が重なると、重なりの浅い軸へ互いを押し返すので、数フレームで重なりが解けます。一点から広がるクイック操作メニューに向きます。09 の測定値は、累積面積が18.6%、動いたフレームが15/23でした。

一つのアンカーの周りで押し合って落ち着くポップオーバーの輪
新しいタブで開く

09 · 一つのアンカーの周りで押し合って落ち着くポップオーバーの輪 — 計測 累積面積 18.613% · 動いたフレーム 15/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

REPELRADIUSSETTLEANGLE_OFFSET
        ov = 0;
        for (let i = 0; i < count; i += 1) {
            for (let j = i + 1; j < count; j += 1) {
                const dx = xs[j] - xs[i];
                const dy = ys[j] - ys[i];
                const gx = (cw[i] + cw[j]) / 2 - Math.abs(dx);
                const gy = CHIP_H - Math.abs(dy);
                if (gx <= 0 || gy <= 0)
                    continue;
                ov += 1;
                if (gx < gy) {
                    const f = gx * REPEL * 0.25 * (dx >= 0 ? 1 : -1);
                    vxs[i] -= f;
                    vxs[j] += f;
                }
                else {
                    const f = gy * REPEL * 0.25 * (dy >= 0 ? 1 : -1);
                    vys[i] -= f;
                    vys[j] += f;
                }
            }
        }

値がポップオーバーを変えるところ — ばねの強さ・矢印の補間・反発

動きがはっきり変わる値は三つです。以下の数字は、ブラウザーの描画ではなく、各場面のソースを偽の DOM の上で直接動かし、60Hz のティックで数えた値です。

強さを0.14から0.32に — 速くなるが行き過ぎも増える

02 の STIFFNESS を上げると、開き始めから0.95に届くまでが150msから100msに早まります。戻す力が強くなった分だけ目標を行き過ぎる幅も増え、最大の scale は1.09から1.26になり、1との差が0.02以内に収まるまでの時間は317msから367msに延びます。

補間を0.15から0.4に — しっぽがアンカーをほとんど見失わない

04 の ARROW_LERP を上げると、デモの2400msのあいだ、矢印の先とアンカー中心の距離は、最大が49pxから13px、平均が28.5pxから8.0pxに縮みます。本体側の BODY_LERP が0.35なので、0.4では本体としっぽがほぼ同じ速さになり、別々に動く差はなくなります。css popover arrow の向きを滑らかに保つ値は、この ARROW_LERP が握っています。

反発を0.6から1.4に — 舞台の大きさで結果が分かれる

09 の REPEL を上げると、最初に重なっていた11組が解けるフレームは、440×252の舞台では7から5に減ります。幅272pxの狭い舞台では同じ変更で7から11に増え、落ち着くフレームも50から53に遅れます。輪全体の幅は値に関係なく281px(狭い舞台は248px)のままなので、直径を決めるのは RADIUS です。

最後に描画して得た九つの値を、取り込み480×300の24枚から表にしました。面積がいちばん大きい場面は 05 の54.5%、いちばん小さい場面は 07 の18.3%で、動いたフレームがいちばん少ない場面は 08 の14/23です。

項目 累積面積 平均強度 動いたフレーム
01 衝突反転 52.1% 150.9 23/23
02 ばねで開く 29.8% 84.4 16/23
03 安全三角形 34.0% 76.4 18/23
04 矢印の補間 36.3% 63.8 23/23
05 ゴムのドラッグ 54.5% 170.4 23/23
06 入れ子の階段 23.1% 56.7 16/23
07 強調ピル 18.3% 126.1 19/23
08 ノイズのマスク 33.9% 70.7 14/23
09 ポップオーバーの輪 18.6% 82.9 15/23

どこで壊れるか — 重なったとき・狭い画面・動きを減らす設定

舞台が狭いと横の候補が消える

01 の横向きの候補には、POP_W の116に GAP の12を足した128pxが要ります。幅294pxの舞台でアンカーが真ん中にあると左右の空きは125pxしかなく、上と下だけが候補に残ります。

粘る幅がないと向きが行き来することがある

FLIP_BIAS を0にすると、今の側が最高点よりわずかでも低くなった瞬間に向きを変えるコードになり、二つの点が近い場所をアンカーが通るとき向きが行き来しうると読めます。これは条件式を読んで出した推測で、0に戻して確かめてはいません。

ばねが行き過ぎるとパネルが目標より大きくなる

02 の行き過ぎは最大1.09なので、幅156pxのパネルが一瞬だけ約170pxまで大きくなります。PAD は目標の大きさ1を基準にした位置しか止めません。変形の基準点は、幅440で場面ごとに ORIGIN_X が62.4・78.0・93.6、ORIGIN_Y はいつも-24で、パネルが端に押されても基準点はアンカーに残ります。

三角形は6ティック前の位置から始まる

03 の頂点はアンカーではなく6ティック前のポインターの位置なので、止まっていれば三角形はポインターから始まります。デモのポインターは最高で約267px/sで通りますが、SPEED_MIN を60から400に上げると、しきい値がデモの最高速度より高くなり、三角形の中を通っていても速さが足りないと判定されて項目2がすぐ開き、切り替えが1517msの一回から483msと1517msの二回に増えます。閉じるのは、ポインターがメニューの左へ出たときだけです。

矢印の先は本体の中だけで動く

04 の先は本体の中の14〜154pxに切られるので、アンカーが本体の外へさらに出ると先は本体の角に貼り付きます。アンカーの範囲を SHIFT_MAX で縛ってあるのはそのためです。

伸びたポップオーバーが舞台の外へ8px切れる

05 で許容範囲を越えた距離が10・40・100・300pxのとき、伸びる長さは4.6・12.3・18.6・23.9pxで、28pxに近づきながら寝ていきます。LAG の28が EDGE_PAD の20より8大きいので、いちばん伸びたときはポップオーバーの8pxが舞台の外へ出て切れます。

明るさのフィルターは文字まで暗くする

06 は深さに応じて filter: brightness() を使うため、ポップオーバーの中の文字も一緒に暗くなり、三段目は0.80です。幅294pxでは段の間の横幅 stepX が37pxに縮み、階段が狭くなります。

ピルは目標の高さを行き過ぎて戻る

07 では、ピルの高さが32から40へ変わるとき最大41.9まで伸びて戻り、位置が4から100へ動くときは最大122.8と、変化量のおよそ24%を行き過ぎます。どちらもソースを直接動かして数えた値です。

ノイズのマスクは毎フレーム文字列を作る

08 の頂点の半径は、基準の r の0.76倍から1.24倍のあいだを行き来します。頂点ごとの半径は基準の r の±24%(NOISE_AMP が0.24)で揺れるので、目標の radius は、いちばん遠い角までの距離 far から far * 1.05 / (1 - NOISE_AMP) と求めます。こうすると最後まで角が残りません。毎フレーム48点の文字列を二つの層ぶん作り、開く割合 p が1になるとマスクを外して負荷を止めます。

狭い舞台ではチップが重なりやすい

09 の目標の楕円の横半径は min(RADIUS * 1.45, w / 2 - 48) なので、幅272pxでは88pxに縮み、440pxの104.4pxより隣のチップが近づきます。

九つの場面をまとめて受け取るなら、下のカードの zip をパスワード get7ygpn で開いてください。ソースと React 版が一緒に入っています。

変形三つとアクセシビリティ

名前 値 感じ
ばねの強さを変える 02 STIFFNESS 0.14 → 0.32 強さを 0.14 から 0.32 に上げると、開き始めてから 95% に届くまでの時間は 150ms から 100ms に縮みますが、目標を行き過ぎる幅は 9% から 26% に広がり、揺れが収まるまでも 317ms から 367ms に延びます。
矢印の補間を変える 04 ARROW_LERP 0.15 → 0.4 補間係数を 0.15 から 0.4 に上げると、矢印の先とアンカー中心の最大距離は 49px から 13px に、平均は 28.5px から 8px に縮み、しっぽはアンカーをほとんど見失いません。本体の BODY_LERP(0.35)に近づくので、しっぽと本体が別々に動く差も小さくなります。
押し合う力を変える 09 REPEL 0.6 → 1.4 押し合う力を 0.6 から 1.4 に上げると、最初に重なっている 11 組が解けるまでのフレーム数は 440×252 の舞台では 7 から 5 に減りますが、272×174 では 7 から 11 に増え、落ち着くまでも 50 から 53 フレームに遅れます。輪全体の幅はどちらも同じ(281px、狭い舞台は 248px)で、直径は RADIUS が決めます。

三つの変形の数字は、上で書いたとおりスタブの DOM でソースを直接動かして測った値です。

操作は九つの場面のどれも <button> かポインターで行えます。keydown を持つのは 09 を除く八つで、01・04・05 はアンカーにフォーカスを置いて矢印キーで動かし(01・05 は四方向、04 は左右)、07 は上下の矢印キーで項目のフォーカスを移し、端まで来ると最初に戻ります。Esc で閉じるのは 02・03・06・08 の四つだけで、02 と 08 は閉じた後でアンカーへフォーカスを返し、06 はその段を開いたボタンへ返し、03 は閉じるだけです。aria-expanded を更新するのも 02・03・06・08 だけです。

setPointerCapture と touch-action: none はドラッグのある 01・04・05 にだけあるので、残りの六つの場面は iframe の中でタッチのスクロールを止めません。状態の行の aria-live は九つの場面すべてに一行ずつ置き、デモの再生中は off、利用者が触れたか動きを減らす設定がオンのときは polite になります。

prefers-reduced-motion がオンなら、九つの場面とも自動のデモと動きのループを始めず、結果の配置を一度だけ描きます。01 は反転した位置、02 と 08 は開いた状態、04 と 05 は右の端、06 は三段とも開いた状態、09 は160ティック先まで進めて落ち着いた状態で止まります。

css popover dropdown だけで足りるか?

開くときの大きさや透明度の変化は、02 のばねや 08 のマスクのように数行で書けますが、画面の端のどちらに出るかは、アンカーと四辺の距離を知らないと決まりません。だから 01・04・05 は、動きより先に位置を求める計算がコードの中心です。css popover dropdown のようにメニューが並ぶ場面でも、07 のとおり、動きの層は位置の層の上に一枚重ねる形になります。

HTML の popover 属性を使うか <div> を自分で動かすかは、開いた状態を誰が管理するかの選び方で、位置をどう決めるかは別の決まりです。この編は、その決まりのほうを九通りに分けて試しました。段が重なるなら 06、項目が多いなら 07、チップが重なるなら 09 のように、場面一つが悩み一つを受け持ちます。

css popover arrow を使い回す前によくある質問

css popover vs dialog はどう選びますか

popover 属性は、ほかの内容の上に小さな板を出して開閉を任せる仕組みで、<dialog> 要素はダイアログとして別に用意された要素です(どちらも MDN で仕様が説明されています)。この編のデモは popover 属性も <dialog> も使っていないので、二つの違いをデモの結果から語ることはできません。ここで比べられるのは、板をどこに置くかの計算のほうです。

位置を自分で計算しても大丈夫ですか

大丈夫です。九つの場面がそのやり方で動いています。アンカーの座標と舞台の大きさから translate3d の値を求め、幅が変わるたびに ResizeObserver が描き直させます。位置は nx・ny のような正規化した座標か、描画ごとの clientWidth から計算し直すので、古いピクセル値は残りません。

ライブラリや外部リクエストは必要ですか

要りません。九つのファイルに script src・import・fetch はなく、場面一つなら index.html と style.css を持っていくだけで動きます。

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

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