対称
対称の作り方
4 本のバーが左右へ開きながら伸び、中央のスクエアがふくらみ、いちばん伸び切ったところで一瞬そろって止まって、同じ道を帰ってくる。完璧に申し合わせたように見えるこの動きは、0 から 1 へ上がり、少し止まって 0 へ帰る数値 1 本が動かしている。各図形はその数値に自分の動き幅を掛けて読み、鏡写しの正体は動き幅の符号が左右で逆なことだ。中央のスクエアは同じ数値で半回転までする。数値 1 本と図形ごとの動き幅を、短い関数に書き起こして読み解く。
- Published
- 2026年6月11日
- Topics
- Pulse · Hold · Easing · SVG
パルス 1 本と図形 5 つを用意する
- 縦長バー 4 本 — 幅は
w・両端は丸キャップ・止まっているときの高さはh - 中央の角丸スクエア — 止まっているときの大きさは
wとh・角はわずかに丸める - 外側のバー 2 本: 高さは
growHのぶん伸び、外向きにslideのぶん動く — この 2 本にいちばん大きな動き幅を与える - 内側のバー 2 本: 同じ
growHとslideを、外側より控えめに - 中央のスクエア:
growWとgrowHで幅も高さもふくらみ、spinで半回転する - パルスのキーは 4 個 — 0 → 1 → (1 のまま) → 0 の並びで、どのフレームに打つかは
pulseKeysで自分で決める
部品はこれで全部だ。組み立ては、フレーム番号を渡すと図形の置き方の一覧を返す関数をひとつ書くだけ — パルスを 1 回計算して、5 つの図形がそれぞれの動き幅で読む。
// 動かしているのはパルス 1 本。図形はみんな、それを自分の動き幅で読むだけ。
// 小文字ではじまる裸の名前(riseStart や barW)は、自分の画面に合わせて決める数 —
// この記事が持ち帰ってほしいのは値ではなく、この「形」だ。
const ease = cubicBezier(...easeHandles); // CSS の cubic-bezier() と同じ規格 — 緩急は好みの 4 つの数で
// パルスに打つキーフレーム: 0 → 1 → (1 のまま) → 0。間は ease で埋める
const pulseKeys = [
{ frame: riseStart, value: 0, curve: ease }, // ここから伸びはじめる
{ frame: holdStart, value: 1, curve: ease }, // 伸び切る — ここから「ため」
{ frame: holdEnd, value: 1, curve: ease }, // ためおわり — 同じカーブのまま下りになる
{ frame: loopEnd, value: 0, curve: ease }, // 戻り切って静止
];
// 打ったキーフレームのあいだをカーブで埋めて補間する — この記事の動きはぜんぶこの形で書ける
function keyAt(keys, frame) {
if (frame <= keys[0].frame) return keys[0].value; // 最初のキー以前 — 最初の値のまま
if (frame > keys.at(-1).frame) return keys.at(-1).value; // 終わりより後 — 最後の値のまま
const next = keys.findIndex((key) => frame <= key.frame); // いま向かっている先のキーの番号
const a = keys[next - 1], b = keys[next]; // 手前のキーとの 2 個で区間が決まる
const t = (frame - a.frame) / (b.frame - a.frame); // 区間の進み具合 0〜1
return a.value + (b.value - a.value) * a.curve(t); // 差をカーブの形で進める
}
// 図形ごとの動き幅: 置き場所と「パルス 1 につきどれだけ動くか」。
// 外側のバーほど大きく、左右では slide の符号だけが逆 — 鏡写しの正体はこの符号
const shapes = [
{ x: outerLeftX, w: barW, h: barH, growW: 0, growH: outerGrow, slide: -outerSlide, spin: 0 },
{ x: innerLeftX, w: barW, h: barH, growW: 0, growH: innerGrow, slide: -innerSlide, spin: 0 },
{ x: centerX, w: squareW, h: squareH, growW: squareGrow, growH: squareGrow, slide: 0, spin: halfTurn },
{ x: innerRightX, w: barW, h: barH, growW: 0, growH: innerGrow, slide: innerSlide, spin: 0 },
{ x: outerRightX, w: barW, h: barH, growW: 0, growH: outerGrow, slide: outerSlide, spin: 0 },
];
// フレーム番号を渡すと、図形の置き方の一覧を返す関数(loopFrames ごとにループ)
function shapesAt(frame) {
const pulse = keyAt(pulseKeys, frame % loopFrames);
return shapes.map((s) => ({
cx: s.x + s.slide * pulse, // 横の置き場所 — 左は引かれ、右は足される
cy: stageSize / 2, // 縦は固定 — 画面の中心の高さ
width: s.w + s.growW * pulse,
height: s.h + s.growH * pulse,
angle: s.spin * pulse, // 中央だけ halfTurn(半回転)まで回る
}));
}ease がこの動きの顔を握っている。中身は CSS の cubic-bezier() とまったく同じ計算で、easeHandles に置く 4 つの数は bezier-easing のような既製のライブラリ にそのまま渡せる。
カーブは出だしを深く溜めて、中盤で一気に進み、終わり際を長くなだらかに着地する。行き — pulseKeys の最初の区間 — はこの 1 本。帰り — 最後の区間 — も同じカーブで、キーの値が 1 から 0 へ並ぶぶん、同じ進み方のまま値だけが下りになる — だから行きと帰りが同じ顔になる。
中ほどの 2 つのキーは値がどちらも 1 で、そのあいだ値は動かない。この『ため』がこの動きの見せ場だ。ease の 4 つの数を変えると緩急だけが変わり、ためは打ってあるキーフレームが守り続ける。
画面に置くときは、好きな一辺 stageSize の正方形を viewBox にして、値を rect に渡す — 左上は中心から半分を引いた位置(x = cx - width / 2)、バーの角丸 rx は幅の半分にすると丸キャップになり、中央のスクエアは小さめの角丸にする。回転は SVG の transform="rotate(angle cx cy)" の 1 行で、図形が自分の中心を軸に回る。
あとは requestAnimationFrame で、経過秒に毎秒のコマ数を掛けて frame を出し、loopFrames で余りを取れば、上のデモと同じ作りのループになる。パルスは最後のキーで 0 に帰り着き、ループの境目をまたいでも 0 のまま — 継ぎ目は、止まっている区間の中に隠れる。
図形ごとの動き幅は遊び場だ。バーを 6 本に増やしても、slide の符号を全部そろえて片側へ流しても、spin を外側のバーに配っても、パルス 1 本の構造はそのまま通用する。パルスを読む先を不透明度や色の濃さに変えても同じ理屈だ — そろえたい動きを全部、同じ 1 本の数値につなぐのが、この組み立ての芯になる。