アドレスバーを見てください。https://hashgk.com/ を開くと、# から先が動きます。
URL にアニメーションをつける話は、2020年に takashimelon さんが Qiita に書いています(URLにアニメーションをつける、477 LGTM)。location.hash を setTimeout で書き換えるだけ。仕組みに新しいところは何もありません。自分もそれを読んで作りました。
書けることがあるとしたら、そこから先です。8コマの月の満ち欠けを107秒の話に伸ばして、実際に人が来るコーポレートサイトに置いた。そうしたら3か所で壊れました。1つはエラーが出ないので気づきにくく、1つは元記事の範囲では絶対に踏まないやつです。
設置先は Simple Custom CSS and JS(WordPress)。以下、順に潰していった記録です。
1回目|エラーも出ずに、何も起きない
置きました。動きません。
コンソールを開いても、赤い行はゼロ。出ていたのは X の埋め込みウィジェットの 429 と、広告ブロッカーの ERR_BLOCKED_BY_CLIENT だけ。どちらもこのスクリプトとは関係がない。構文エラーなら止まるはずなので、少なくとも読み込みは通っている。
このとき置いていたのは、元記事の8行からだいぶ離れた版でした。履歴を汚したくなくて history.replaceState() を主にして、失敗したら location.replace()、それも駄目なら location.hash へ落とす3段構え。あわせて、管理画面と iframe と prefers-reduced-motion では再生しない条件も足していました。
疑ったのは3つです。
まず、WordPress が保存時に絵文字を実体参照へ変換している線。wp_encode_emoji は DB の文字セットが utf8 のときに走るので、'⬛' が '⬛' という文字列になっていてもおかしくない。外れでした。元記事の8行をそのまま貼ると絵文字は普通に動く。utf8mb4 です。
次に、replaceState が握りつぶされている線。短い間隔で呼び続けると制限がかかる、という話は前から出ているそうです。しかもこの場合は例外が飛んでこないので、上の3段構えが1段目から動きません。 catch に入らなければ mode は永遠に最初のままです。筋は通っている。これも外れでした。コンソールで1行打つとアドレスバーは変わり、125ミリ秒間隔で200回連打しても止まらない。
let i = 0;
const t = setInterval(() => {
history.replaceState(null, '', '#' + (i++));
if (i > 200) clearInterval(t);
}, 125);
残ったのが、自分で足した抑止条件でした。
matchMedia('(prefers-reduced-motion: reduce)').matches
true です。自分の Mac のアクセシビリティ設定で「視差効果を減らす」が入っていました。いつ入れたのかも覚えていません。
つまりバグではありません。動きが苦手な人への配慮のつもりで自分が書いたガードが、仕様どおりに動いていた。 引っかかったのが自分だっただけです。
try/catch は、呼ばれたあとしか守ってくれません。今回のように play() へ到達していない種類の失敗には手が出せない。何も言わずに何もしない分岐を自分で書いておいて、その存在を忘れていた。
だから直し方も、ガードを外すことではありませんでした。戻したうえで、止まった理由を言わせる。
var reduced = global.matchMedia &&
global.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduced) {
console.log('動きを減らす設定が有効なので再生していません。' +
'URLAnimation.play() で手動再生できます。');
} else {
api.play();
}
これで、次に同じ画面を見た人は5秒で分かります。
置き先も関係します。hashgk.com は、外で働くのが難しい人たちがリモートで運営している会社のサイトです。動きが苦手な人のための設定を、動くおもちゃのために黙って踏み越えるわけにはいかない。切り分けのあいだ外していたガードは、原因が分かった時点で戻しました。
2回目|動いた。ただし山が記号になる
ガードを全部外して、書き込みも元記事と同じ location.hash に戻しました。動きました。
いま思えば、ここで2つをいっぺんに変えたのがよくなかった。どちらが効いたのか分からないまま先へ進んでしまい、切り分けは後日になっています。変数は1個ずつ動かす、と何度も言われている話をそのまま踏みました。
書き込み方式のほうは、結果としてはどちらでも動きます。replaceState は200コマぶん連打しても止まりませんでした。ただ、この作品は1周856コマあります。そこまでは確かめていないので、いまは location.hash のまま置いています。履歴が積まれて戻るボタンが押しにくくなるのは分かったうえでの妥協です。URLAnimation.mode('replace') で location.replace('#' + text) に切り替えられるようにはしてあります。
そして動いたら、次の問題が見えました。
山のマスに ⛰ が出て、その右に %EF%B8%8F という文字列が並んでいる。
3回目|絵文字を選び直す
%EF%B8%8F は U+FE0F を UTF-8 でパーセントエンコードしたものです。異体字セレクタ16、通称 VS16。「直前の文字を絵文字として描画してください」という指示子で、それ自体には見た目がありません。
⛰️ という絵文字は、実は2文字あります。U+26F0(本体)と U+FE0F(セレクタ)。アドレスバーは URL を組み直すときにこの2文字を切り離してしまうので、本体はモノクロの記号として残り、セレクタだけが裸で表に出る。
同じことが起きるのを、自分の原稿では3つ踏んでいました。
⛰️ U+26F0 U+FE0F 山
☁️ U+2601 U+FE0F 雲
🕊️ U+1F54A U+FE0F 鳩
Unicode には Emoji_Presentation というプロパティがあって、これが Yes の文字はセレクタなしでも最初から絵文字として描かれます。No の文字は記号として描かれるので、絵にするには VS16 が要る。URL に置くなら Yes の文字だけで組むしかありません。
No に落ちるのは、だいたいこのあたりです。
- U+2600〜U+27BF(その他の記号・装飾記号)。⛰ ☁ ⚠ ✔ ✈ ☂ ▶ など。ここは大半が
No - U+2B00〜U+2BFF。ただし ⬛ ⬜ ⭐ は
Yes - U+1F3D4〜U+1F3DF の風景シリーズ。🏔 🏕 🏖 🏗 🏘 🏙 🏚 🏛 🏜 🏝 🏞 🏟 が全部
No
逆に U+1F300 以降の絵文字ブロックは大半が Yes なので、迷ったらそちらから選ぶのが速い。同じ記号ブロックでも ⛅ ⛵ ⛄ ⛔ ⛪ ⛲ ⛳ ⛺ ⛽ は例外的に Yes です。
差し替えたのは3文字だけでした。
mount: 0x1F5FB, // ⛰️ → 🗻
cloud: 0x26C5, // ☁️ → ⛅
dove: 0x1F426, // 🕊️ → 🐦
全856コマを走らせて U+FE0F がゼロになったことを確認してから置き直しています。
余談ですが、この制約は地図記号と天気記号のあたりに集中します。❤️ ⚠️ ▶️ ✔️ ✈️ ☂️ はどれも VS16 が要る。ハートを URL に出そうとして記号になった人はたぶん自分だけではないはずで、代わりに 💗 💖 💓 を使うと解決します。
おまけ|空白の扱い
幕間に字幕を出す作りにしていて、最初は あきの はじめ のように空白を入れていました。当然 %20 になります。読めたものではない。
字幕から空白を全部抜きました。あきのはじめ。ひらがなだけなら詰めても読めます。
半角スペースが URL でどうなるかは誰でも知っている話なのに、絵文字のことばかり考えていて素通りしました。表示されるのが自分のブラウザのアドレスバーなので、テスト環境で気づく機会がない。ここは目視しかありません。
12マスをどう組むか
元記事は1コマが絵文字1個です。これだと8コマで表現が尽きる。
幅12マスの配列を用意して、背景を敷いて、その上にスプライトを重ねて join('') する形にしました。
var W = 12;
function fill(c) {
var a = [], i;
for (i = 0; i < W; i++) { a.push(c); }
return a;
}
function put(a, x, c) {
var i = Math.round(x);
if (i >= 0 && i < W) { a[i] = c; }
return a;
}
これだけで前後関係が出ます。太陽を描いたあとに雲を描けば、雲が手前を横切る。速度を変えて2枚流せば視差になる。
もうひとつ効いたのが、12マスを固定の地図として扱ったことです。
0 1 2 3 4 5 6 7 8 9 10 11
🏢 🏬 📮 🌳 🌊 🌊 🌊 🌲 🗻 🌾 📪 🏡
街のポストが2、おばあちゃんの家のポストが10。全部の幕がこの同じ地図を使い、その上を手紙が右へ、別のものが左へ動きます。マスに意味が固定されるので、位置そのものが情報になる。📪 が 📫 に変わる1コマが、この話でいちばん大事なコマです。
時間の飛躍も1次元でやれました。手紙が山で止まっている幕では、0マス目を 🌞 と 🌙 で入れ替えて11マス目の 👵 を 💤 にする。1秒で1日が過ぎます。手紙は8マス目に置かれたまま動かない。
字幕の幕を挟むのも効きます。絵文字の幕と文字の幕を交互に置くと、無声映画の字幕と同じ役割をしてくれる。絵が語れないところを引き受けてくれます。
コード全文
<script src="url-letter.js"></script> で読み込むだけで再生します。停止は URLAnimation.stop()。
ソース中に絵文字リテラルを1文字も書いていないのは、WordPress が DB の文字セット次第で保存時に絵文字を実体参照へ変換する(wp_encode_emoji)ためです。最初はこれを疑いました。結果としては外れで、utf8mb4 なら起きない話でしたが、コードポイントから組み立てておいて損はないので、そのままにしてあります。
(function (global) {
'use strict';
var doc = global.document;
if (!doc) { return; }
var cp = String.fromCodePoint;
var E = {
office: cp(0x1F3E2), store: cp(0x1F3EC), post: cp(0x1F4EE),
tree: cp(0x1F332), tree2: cp(0x1F333), water: cp(0x1F30A),
mount: cp(0x1F5FB), rice: cp(0x1F33E),
boxOff: cp(0x1F4EA), boxOn: cp(0x1F4EB), home: cp(0x1F3E1),
kid: cp(0x1F9D2), gran: cp(0x1F475), letter: cp(0x1F48C),
truck: cp(0x1F69A), boat: cp(0x26F5), train: cp(0x1F682),
boom: cp(0x1F4A5), swirl: cp(0x1F300),
sun: cp(0x1F31E), moon: cp(0x1F319), zzz: cp(0x1F4A4),
spark: cp(0x2728), heart: cp(0x1F497), bird: cp(0x1F426),
what: cp(0x2753), caret: '\u258D'
};
var W = 12;
var MAP = [E.office, E.store, E.post, E.tree2, E.water, E.water,
E.water, E.tree, E.mount, E.rice, E.boxOff, E.home];
function map() { return MAP.slice(); }
function put(a, x, c) {
var i = Math.round(x);
if (i >= 0 && i < W) { a[i] = c; }
return a;
}
function ride(i) {
if (i >= 4 && i <= 6) { return E.boat; }
if (i >= 7 && i <= 8) { return E.train; }
return E.truck;
}
function card(text, sec) {
return {
no: '間', ttl: text, sec: sec || 3, fps: 5, text: true,
render: function (f) {
var a = text.split('');
return a.slice(0, Math.min(f + 1, a.length)).join('') +
(f % 2 ? E.caret : '');
}
};
}
var scenes = [
card('12マスのてがみ', 3),
{ no: '一', ttl: 'おばあちゃんはとおい', sec: 6, fps: 8,
render: function (f) {
var a = map();
put(a, 0, E.kid);
put(a, 11, E.gran);
if (f > 8) { put(a, 1 + (f - 8) * 0.28, E.bird); }
return a.join('');
} },
card('とおいね', 3),
{ no: '二', ttl: 'ポストまで', sec: 6, fps: 8,
render: function (f, F) {
var a = map();
put(a, 11, E.gran);
if (f < 18) {
put(a, (f / 17) * 2, (f % 4 < 2) ? E.letter : E.kid);
} else if (f < 26) {
put(a, 0, E.kid);
put(a, 2, (f % 4 < 2) ? E.letter : E.post);
} else {
put(a, 2 - ((f - 26) / (F - 27)) * 2, E.kid);
}
return a.join('');
} },
{ no: '三', ttl: 'たびだち', sec: 14, fps: 8,
render: function (f, F) {
var a = map();
put(a, 0, E.kid);
put(a, 11, E.gran);
var x = 2 + (f / F) * 6.4;
put(a, x, (f % 8 < 2) ? E.letter : ride(Math.round(x)));
return a.join('');
} },
card('ところが', 3),
{ no: '四', ttl: 'やまでとまる', sec: 8, fps: 8,
render: function (f, F) {
var a = map();
put(a, 11, E.gran);
var p = f / F;
if (p < 0.22) { put(a, 8, E.train); }
else if (p < 0.34) { put(a, 8, E.boom); }
else {
put(a, 8, (f % 6 < 3) ? E.train : E.swirl);
if (f % 10 < 4) { put(a, 7, E.swirl); }
}
return a.join('');
} },
{ no: '五', ttl: 'まいにちポストをみる', sec: 10, fps: 8,
render: function (f) {
var a = map();
put(a, 8, E.train);
var night = (f % 8) >= 4;
put(a, 0, night ? E.moon : E.sun);
put(a, 10, E.boxOff);
put(a, 11, night ? E.zzz : E.gran);
return a.join('');
} },
card('あきのはじめ', 3),
{ no: '六', ttl: 'ふたたびうごく', sec: 10, fps: 8,
render: function (f, F) {
var a = map();
var p = f / F;
put(a, 11, E.gran);
if (p < 0.85) {
var x = 8 + (p / 0.85) * 2;
put(a, x, (f % 8 < 2) ? E.letter : ride(Math.round(x)));
}
put(a, 10, p > 0.75 ? E.boxOn : E.boxOff);
return a.join('');
} },
{ no: '七', ttl: 'よむ', sec: 6, fps: 8,
render: function (f) {
var a = map();
put(a, 10, E.boxOn);
put(a, 11, (f % 8 < 4) ? E.gran : E.letter);
if (f > 16) { put(a, 10, (f % 6 < 3) ? E.spark : E.boxOn); }
if (f > 30) { put(a, 9, E.heart); }
return a.join('');
} },
card('おへんじは', 3),
{ no: '八', ttl: 'かえりみち', sec: 10, fps: 8,
render: function (f, F) {
var a = map();
put(a, 0, E.kid);
put(a, 10, E.boxOn);
var x = 11 - (f / F) * 8;
put(a, x, (f % 16 < 2) ? E.what : ride(Math.round(x)));
return a.join('');
} },
{ no: '九', ttl: 'とうちゃく', sec: 8, fps: 8,
render: function (f, F) {
var a = map();
var p = f / F;
put(a, 0, E.kid);
if (p < 0.30) { put(a, 3 - p * 6.6, E.truck); }
else if (p < 0.42) { put(a, 1, E.truck); }
else { put(a, 1, E.gran); }
if (p > 0.55) { put(a, 2, (f % 6 < 3) ? E.spark : E.post); }
if (p > 0.80) { put(a, 2, E.heart); }
return a.join('');
} },
card('12マスは', 3),
{ no: '十', ttl: 'ならんで', sec: 6, fps: 8,
render: function (f) {
var a = map();
put(a, 0, E.kid);
put(a, 1, E.gran);
put(a, 3 + (f * 0.16) % 9, E.bird);
if (f % 14 < 7) { put(a, 2, E.heart); }
return a.join('');
} },
card('1マスになった', 5)
];
scenes.forEach(function (s) { s.F = Math.round(s.sec * s.fps); });
var mode = 'hash';
function write(text) {
if (mode === 'replace') {
try { global.location.replace('#' + text); return; }
catch (e) { mode = 'hash'; }
}
global.location.hash = text;
}
function restore() {
try {
global.history.replaceState(null, '',
global.location.pathname + global.location.search);
} catch (e) { global.location.hash = ''; }
}
var si = 0, fi = 0, timer = null, playing = false, speed = 1, resume = false;
function step() {
var s = scenes[si];
write(s.render(fi, s.F));
if (++fi >= s.F) { fi = 0; si = (si + 1) % scenes.length; }
timer = global.setTimeout(step, (1000 / s.fps) / speed);
}
var api = {
scenes: scenes,
play: function () { if (!playing) { playing = true; step(); } return api; },
pause: function () { playing = false; global.clearTimeout(timer); return api; },
stop: function () { api.pause(); restore(); return api; },
seek: function (n) { si = ((n % scenes.length) + scenes.length) % scenes.length; fi = 0; return api; },
speed: function (x) { speed = x || 1; return api; },
mode: function (m) { mode = (m === 'replace') ? 'replace' : 'hash'; return api; }
};
doc.addEventListener('visibilitychange', function () {
if (doc.hidden) { resume = playing; api.pause(); }
else if (resume) { resume = false; api.play(); }
});
global.addEventListener('beforeunload', restore);
global.URLAnimation = api;
var reduced = global.matchMedia &&
global.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduced) {
console.log('動きを減らす設定が有効なので再生していません。' +
'URLAnimation.play() で手動再生できます。');
} else {
api.play();
}
})(window);
離脱前に # を消しているのは、共有された URL がパーセントエンコードの塊にならないようにするためです。12マスの絵文字は、エンコードすると140文字を超えます。
次の自分に渡すメモ
- 動かないものを直すとき、変数は1個ずつ動かす。今回は書き込み方式と抑止条件を同時に変えたせいで、切り分けが後日になった
- try/catch は呼ばれたあとしか守らない。そもそも呼ばれていない失敗には無力で、こちらのほうが見つけにくい
- 自分で足した抑止条件は、黙って止まらせない。止めた理由をログに出して、手で動かす口を残す
- 開発機の設定は読者の環境ではない。自分の Mac で「視差効果を減らす」が入っていたことに、今回まで気づいていなかった
- URL に文字を出すときは
Emoji_Presentation=Yesだけで組む。U+2600 台の記号は疑ってかかる - 表示先がアドレスバーなので、自動テストが書けない。目視の回数を最初から予算に入れる
ふだんは raplsworks.com で、WordPress プラグイン開発や Claude Code まわりのことを書いています。
https://raplsworks.com/

