スタジオおもち by HASH のサイト( https://hashgk.com/ )を開いて、上のアドレスバーを見てください。
# から先が動いています。街があって、ポストがあって、川があって、山があって、その向こうにおばあちゃんの家がある。そこを手紙が渡っていきます。107秒で一周する話です。
置いたのは私です。仕事として頼まれたわけではなく、勝手に作って勝手に置きました。この記事はその紹介と、作り方の記録です。
何が映っているのか
12個のマスが横一列に並んでいます。
これがそのまま地図です。
0 1 2 3 4 5 6 7 8 9 10 11
🏢 🏬 📮 🌳 🌊 🌊 🌊 🌲 🗻 🌾 📪 🏡
左端が街。2番目のマスが街のポスト。真ん中が川。8番目が山。右端がおばあちゃんの家で、その手前の 📪 が家のポストです。
物語は17の幕に分かれていて、絵の幕と字幕の幕が交互に来ます。
- 子どもが手紙をポストに入れる
- 手紙がトラックと船と汽車を乗り継いで山へ向かう
- 山で止まる
- おばあちゃんが毎日ポストを見にいく。ポストは空のまま
- 秋のはじめに手紙が動きだして、届く
- 返事が街へ向かってくる
- 着いたのは手紙ではなかった

この距離が、この話の主題です

1秒で1日が過ぎます

12マスが1マスになったところ
最後の幕で、子どもとおばあちゃんが隣のマスに並びます。
字幕が「12マスは」と出て、「1マスになった」で終わります。
いちばん気に入っているのは、手紙が山で止まっている幕です。左端のマスが 🌞 と 🌙 で入れ替わって、右端のおばあちゃんが 👵 から 💤 になる。1秒で1日が過ぎる作りにしました。手紙は8番目のマスに置かれたまま、何日たっても動きません。
横一列しかない画面でも、時間が流れていることは表現できるらしい。作ってみるまで知りませんでした。
なぜアドレスバーなのか
もとは2020年に Qiita で読んだ記事です。location.hash を書き換えると URL の # から先が変わるので、絵文字を順番に入れ替えれば月が満ち欠けする、という8行くらいの仕掛けでした。
読んだときに、これは画面だなと思いました。8個の絵文字が入るなら12個も入る。12個入るなら背景と主人公を重ねられる。重ねられるなら話が作れる。
そこから逆算して、107秒の無声映画になりました。
サイトにとって何の役にも立ちません。SEO にも売上にも関係がない。ただ、スタジオおもち by HASH は「できることを仕事に」を掲げているところで、サイトの隅で何かがずっと動いているのは、たぶん似合うと思ったのです。気づく人だけが気づけばいい。
どこでつまずいたか
置いたら動きませんでした。
エラーも出ない。
犯人は、自分で足した気づかいでした。
動きが苦手な人がいるので、パソコンの側で「アニメーションを減らす」設定が入っているときは再生しない、という条件を書いていました。そして自分のパソコンで、その設定が入っていた。いつ入れたのかも覚えていません。
条件はきちんと動いていました。壊れていたわけではない。ただ、止まった理由を何も言わずに黙っているので、こちらは壊れたと思い込んで、まったく別のところをずっと探すことになりました。
いまは、止めるときにその理由をブラウザのコンソールへ出すようにしてあります。動きが苦手な人の設定を、動くおもちゃのために踏み越えるわけにはいかないので、条件そのものは残したままです。
次につまずいたのが絵文字です。
動きだしたアドレスバーに、%EF%B8%8F という文字列が混ざっている。
絵文字の中には、実は2文字でできているものがあります。⛰️(山)は、山の記号と「これを絵文字として描いてください」という見えない指示子の2文字。アドレスバーは URL を組み直すときにこの2文字を切り離してしまうので、指示子だけが裸で出てきます。
私が使っていた中では ⛰️ ☁️ 🕊️ の3つが該当しました。それぞれ 🗻 ⛅ 🐦 に差し替えて解決です。❤️ ⚠️ ▶️ ✈️ も同じ仲間なので、URL に出そうとしている人は先に確かめたほうがいい。
3つめは空白でした。字幕に「あきの はじめ」と書いていたら %20 になった。空白を抜いて「あきのはじめ」にしました。ひらがなだけなら詰めても読めます。
どれも、置いてみるまで分からなかったことです。
自分のサイトにも置けます
WordPress なら、Simple Custom CSS and JS というプラグインで JavaScript を追加できます。私もそれで置きました。
まず、いちばん短い形から。
これを貼るだけで月が満ち欠けします。
const moons = ["🌑", "🌒", "🌓", "🌔", "🌕", "🌖", "🌗", "🌘"];
const loop = (arr, count = 0, time = 75) => {
location.hash = arr[count % arr.length];
setTimeout(loop.bind(this, arr, count + 1, time), time);
};
loop(moons);
配列を差し替えれば何でも動きます。時計にするなら ["🕛","🕐","🕑","🕒","🕓","🕔","🕕","🕖","🕗","🕘","🕙","🕚"]。数字を並べればカウントダウンになります。
12マスの画面にしたい場合は、配列を1コマぶんの絵にします。
const W = 12;
function fill(c) {
return Array.from({ length: W }, () => c);
}
function put(a, x, c) {
const i = Math.round(x);
if (i >= 0 && i < W) a[i] = c;
return a;
}
// 青空を12マス敷いて、太陽を左から右へ動かす
let f = 0;
setInterval(() => {
const a = fill("🟦");
put(a, (f % 130) / 10, "🌞");
location.hash = a.join("");
f++;
}, 125); 背景を敷いて、上に何かを置いて、つなげる。これだけです。あとから描いたものが手前になるので、太陽のあとに雲を描けば雲が前を横切ります。
107秒の物語のほうは、幕ごとの描画関数を17個ぶん並べただけのものです。全文はこの記事の末尾に置いておきます。長いですが、上から読める形にしてあります。
置くときの注意が3つ。ひとつは、絵文字を選ぶときに ⛰️ のような2文字組を避けること。ふたつめは、パソコン側で「アニメーションを減らす」が入っている人には再生しないこと。上の全文にはその条件を入れてあります。みっつめは、ページを離れるときに # を消す処理を入れることです。12マスの絵文字は、URL の形にすると140文字を超えます。その状態のリンクを誰かに送ってしまうと、ただの文字化けに見えます。
window.addEventListener("beforeunload", () => {
history.replaceState(null, "", location.pathname + location.search);
}); もう一度、アドレスバーを
hashのホームページ をもう一度開いてみてください。
タイトルが出て、街とおばあちゃんの家がいちばん離れたところに置かれます。子どもがポストまで歩いて、手紙が旅に出る。山で止まって、何日か過ぎて、また動きだす。
最後に「1マスになった」と出て、また最初に戻ります。
107秒です。
読み込みが終わってから、そのぶんだけ待ってもらえたら。
コード全文
url-letter.js として保存して、<script src="url-letter.js"></script> で読み込みます。Simple Custom CSS and JS を使う場合は、新規 JS スニペットを作って中身を貼り、linking type は External File にしてください。停止したいときはブラウザのコンソールで URLAnimation.stop() と打ちます。
(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);
地図を変えたいときは MAP の12個を書き換えるだけです。幕を足したいときは scenes に1つ足す。字幕なら card('すきなことば', 3) と書けば、それだけで1枚増えます。

