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

css receipt 9パターン — 一行ずつ印字・切り取り線・バーコード読み取り・割り勘の場面

css receipt 一枚の中には印字ヘッドの震え・紙の反り・合計の転がりが重なっています — 01番は行ごとに進む距離に震えを足し、06番はロールからほどける紙の円筒の陰影をピクセルごとに計算します。店名も品目も金額もすべて作り物で、紙が印字され、ちぎれ、褪せていく瞬間だけを九つの場面に分けました。

レシート九枚をこのページで印字し、ちぎり、読み取ってみる

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

css receipt 9パターン — 一行送り・切り取り判定・円筒の陰影で作る紙の場面

白い地に等幅の文字を並べればレシートらしく見えはしますが、レシートらしく感じさせるのは、紙が出てきて切れるときの動き方のほうです。そこで01〜08は <canvas> の2Dに紙を計算して描き、09だけが DOM のボタンを transform で動かします。九つの場面の CSS には transition も keyframes のアニメーションもなく、スタイルシートが受け持つのは舞台の比率、ボタンの形、フォーカスの枠、touch-action といった外枠だけです。

時間と乱数は、どの場面も同じ骨組みで回しています

requestAnimationFrame が渡す時刻をためておき、16msの固定ステップ(FIXED_DT)に切って step() を回し、描画は一フレームに一回だけです。一フレームでためる時間は100msで打ち切り、一度に回すステップは6回が上限なので、もたついたフレームのあとでも紙がまとめて跳ね上がることはありません。乱数はシードを固定した lcg 一種類だけで、実際に呼んでいるのは02(切り取りの歯がちぎれる閾値)と04(棒の幅と数字)の二場面です。Math.random・Date.now・fetch・localStorage は九つの index.html のどこにもないので、再読み込みするたびに同じレシートが同じ順番で出てきます。

レシートの文字と税率は作り物です

紙に印字される店名 つきあかり商店 と品目 牛乳・食パン・りんご・卵・みかん、その横の金額は、場面を埋めるために決めた文字です。税率 TAX_RATE の0.1も計算を見せるための数字で、特定の国の税法とは関係がありません。実際の決済画面に移すなら、金額と税の計算は自分のサービスの規則に置き換えてください。

九つの場面をレンダーして数えた動き

幅480×高さ300のウィンドウで2秒のあいだに24枚を撮り、隣り合う二枚ずつの差を取った値なので、動いたフレームは23を分母にしています。

場面 累積面積 強度の平均 動いたフレーム
01 一行印字と震え 23.6% 35.6 23/23
02 切り取り線をちぎる 26.9% 118.4 20/23
03 ギザギザの縁と反り 36.5% 65.8 23/23
04 バーコードの読み取りビーム 15.1% 66.9 23/23
05 転がる合計 8.6% 103.6 23/23
06 ロールの円筒の陰影 32.6% 51.3 23/23
07 感熱紙の褪せ 40.5% 30.7 18/23
08 差し棒の振り子 41.1% 83.4 23/23
09 割り勘 37.7% 138.5 23/23

10%を下回るのは05だけで、画面で変わる場所が数字盤の数桁に限られることから来る小ささです。07が18/23なのは文字が褪せきったあと次の印字を待つ静止の区間が、02が20/23なのは切片が戻るか落ちたあと新しい紙を待つ区間が、2秒の撮影の中に入ったためです。

この場面が合う画面

receipt css template で紙のカードを一枚置くところで止めず、この記事はその紙が動く瞬間のほうを選びました。店舗の決済が済んだ画面で注文内訳を一行ずつ出したいなら01、「レシートを受け取る」を手でちぎる感触にしたいなら02が出発点です。クーポンやレシートカードの縁飾りには03、返品や返金を確認した合図には04、カートの合計と決済サマリーには05が合います。印刷を待つ画面は06、古い履歴を見せる保管箱は07と08、会の費用を人ごとに分ける精算画面は09から組み始めてください。

css receipt design の計算の断片 — ソースから途切れずに抜いた行

断片は各場面の index.html の <script> から、途中を飛ばさずに続いた8〜25行をインデントごと写したものです。関数と定数の名前はソースと同じなので、zip を開いたあと同じ名前で検索すると、断片の前後の文脈がすぐに見つかります。

01印字ヘッドが一行ずつ打つたびに紙が一段進んで細かく震えるレシート印字の場面

一行は180ms(LINE_MS)のあいだ左から不透明度0.3で現れ、行が終わってから160msかけて濃くなり、続く140ms(FEED_MS)で紙が FEED_PX の14px進むときに shiverAt の横揺れが乗ります。行の高さは印字した瞬間の送り量と今の送り量の差だけで決まるので、行ごとに別の動きをかけなくても九行がひとかたまりで上がっていきます。震えは SHIVER_PX の2.4に (1 − k) が掛かって実際の最大は約2.2pxにとどまり、レンダーでは累積面積23.6%・強度35.6で23/23フレームが動きました。

印字ヘッドが一行ずつ打つたびに紙が一段進んで細かく震えるレシート印字の場面
新しいタブで開く

01 · 印字ヘッドが一行ずつ打つたびに紙が一段進んで細かく震えるレシート印字の場面 — 計測 累積面積 23.613% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

FEED_PXLINE_MSSHIVER_PXPAPER_W
    // 続けて送られるあいだの横の震え — 始めは大きく、終わりへ行くほど収まる
    function shiverAt(k) {
        return SHIVER_PX * Math.sin(k * Math.PI * 7) * (1 - k);
    }
    // 一行の縦の位置 — 印字した瞬間の送り量と今の送り量の差だけヘッドから離れる
    function rowY(printedScroll, scrollNow) {
        return HEAD_Y - 9 - (scrollNow - printedScroll);
    }

02切り取り線の下の紙を引き下ろして放すと、基準距離を超えていればちぎれて落ち、届かなければバネで戻る場面

切り取り線の歯21本(STRIP_W 210 ÷ TOOTH_PX 10)は、ちぎれる距離がそれぞれ TEAR_PX の0.45〜1.0倍としてシード461から決まっているので、引いているあいだに一本ずつ別々の瞬間に切れます。ちぎるかどうかは歯とは関係なく、手を放した瞬間に release() が pull ≥ TEAR_PX の一回で決め、超えていれば GRAVITY 2600で落とし、届かなければ SPRING_K 190・減衰17のバネで巻き戻します。レンダーの値は累積面積26.9%・強度118.4で、戻ったあと新しい紙を待つあいだ止まっているため、動いたフレームは20/23です。

切り取り線の下の紙を引き下ろして放すと、基準距離を超えていればちぎれて落ち、届かなければバネで戻る場面
新しいタブで開く

02 · 切り取り線の下の紙を引き下ろして放すと、基準距離を超えていればちぎれて落ち、届かなければバネで戻る場面 — 計測 累積面積 26.888% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

TEAR_PXGRAVITYSPRING_KTOOTH_PX
    const rnd = lcg(461);
    for (let i = 0; i < 40; i += 1) {
        toothBreak.push(0.45 + 0.55 * rnd());
    }
    function teethCount() { return Math.floor(STRIP_W / TOOTH_PX); }
    // 引いた距離に応じて歯が一本ずつ切れる — 歯ごとに切れる距離が違う
    function intactTeeth(d) {
        let n = 0;
        for (let i = 0; i < teethCount(); i += 1) {
            if (d < TEAR_PX * toothBreak[i % 40]) {
                n += 1;
            }
        }
        return n;
    }

03ギザギザの縁と反り上がった角に列ごとに違う影がかかる紙の質感の場面

紙を、中央は平らで両端へ行くほど u × 0.62 だけ傾く曲面とみなし、幅3pxの列100本ごとに法線と光の向きの内積から (1 − dot) × SHADE_MAX を塗ります。光は LIGHT_DEG の38°を中心に SWAY_DEG の46°ずつ2.4秒に一度往復し、84°の端では右端の列の陰影が上限の0.46まで上がって、反った角の影と一緒に片側が沈み込みます。測定では累積面積36.5%・強度65.8、23/23フレームでした。

ギザギザの縁と反り上がった角に列ごとに違う影がかかる紙の質感の場面
新しいタブで開く

03 · ギザギザの縁と反り上がった角に列ごとに違う影がかかる紙の質感の場面 — 計測 累積面積 36.52% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

TOOTH_NCURL_PXLIGHT_DEGSHADE_MAX
    // 今の光の角度(度) — 自動なら基準の角度を中心に揺らし続ける
    function lightDeg() {
        if (manual) {
            return manualDeg;
        }
        return LIGHT_DEG + SWAY_DEG * Math.sin((clock / SWAY_MS) * Math.PI * 2);
    }
    // 縦一列の陰影の強さ — 紙をゆるく曲がった面とみなし、法線と光の向きの内積を使う
    function shadeAt(col, deg) {
        const u = col / SHEET_W * 2 - 1;
        const slope = u * 0.62;
        const nx = -slope;
        const nz = Math.sqrt(1 - Math.min(0.99, slope * slope));
        const rad = deg * Math.PI / 180;
        const lx = Math.sin(rad);
        const lz = Math.cos(rad);
        const dot = clamp(nx * lx + nz * lz, 0, 1);
        return (1 - dot) * SHADE_MAX;
    }

04シードで作った棒幅が並び、ビームが走ると順に明るくなって確認印が灯るバーコード読み取り

棒46本(BAR_N)の幅は lcg(SEED) が1〜4単位から選び、336×118ピクセルのバッファの点ごとにビームとの距離 d から exp(−d² / (2·GLOW_PX²)) を求めて、インクの列はピンク側へ最大0.9、紙は白側へ最大0.55だけ混ぜます。ビームの位置には 5·sin(y/15 + clock/140) が足されるので同じ列でも高さによって明るさが違い、ビームが端に届くと確認の点がミント色に灯り、その瞬間に半径が1.5倍に膨らんでから、すぐに元の大きさへ戻ります。バッファを毎フレーム putImageData で載せるこの場面は、累積面積15.1%・強度66.9・23/23フレームです。

シードで作った棒幅が並び、ビームが走ると順に明るくなって確認印が灯るバーコード読み取り
新しいタブで開く

04 · シードで作った棒幅が並び、ビームが走ると順に明るくなって確認印が灯るバーコード読み取り — 計測 累積面積 15.149% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

BAR_NBEAM_MSGLOW_PXSEED
    function paintBars(t) {
        const data = img.data;
        const sig = 2 * GLOW_PX * GLOW_PX;
        for (let y = 0; y < BH; y += 1) {
            const bx = beamX(y, t);
            const fall = 1 - 0.18 * (y / BH);
            for (let x = 0; x < BW; x += 1) {
                const dx = x - bx;
                const g = Math.exp(-(dx * dx) / sig) * fall;
                const ink = inkAt[x] === 1;
                const base = ink ? INK : PAPER;
                const tint = ink ? PINK : [255, 255, 255];
                const m = ink ? g * 0.9 : g * 0.55;
                const i4 = (y * BW + x) * 4;
                data[i4] = base[0] + (tint[0] - base[0]) * m;
                data[i4 + 1] = base[1] + (tint[1] - base[1]) * m;
                data[i4 + 2] = base[2] + (tint[2] - base[2]) * m;
                data[i4 + 3] = 255;
            }
        }
        offCtx.putImageData(img, 0, 0);
    }

05品目が加わるたびに合計の桁が縦に転がり、税の行が一拍遅れて追いかける合計欄

合計は毎ティック speed = speed × INERTIA + (target − shown) × PULL_K で目標を追って4.2%行き過ぎてから戻り、digitPos は一の位だけを値のまま回して、上の桁はすぐ下の桁が9.9を超える最後の0.1の区間でだけ一段転がします。税の行は計算した目標ではなく画面に見えている合計の10%を、DELAY_MS の260を時定数にした遅延フィルターで受け取るので、合計が転がるあいだは少し遅れてあとを追い、最後に落ち着くのは税の行のほうが先です。動くのが数字盤だけなので累積面積は8.6%と九場面で最も小さいものの、強度は103.6あり、23/23フレームで変化が捉えられました。

品目が加わるたびに合計の桁が縦に転がり、税の行が一拍遅れて追いかける合計欄
新しいタブで開く

05 · 品目が加わるたびに合計の桁が縦に転がり、税の行が一拍遅れて追いかける合計欄 — 計測 累積面積 8.624% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

DIGIT_HINERTIATAX_RATEDELAY_MS
    // 数字盤一枚の位置 — 繰り上がりの区間(すぐ下の桁が9.9を超えるとき)だけ転がる
    function digitPos(value, d) {
        if (d === 0) {
            return value;
        }
        const scale = Math.pow(10, d);
        const base = Math.floor(value / scale);
        const f = value / scale - base;
        return base + smooth((f - 0.9) / 0.1);
    }

06ロールからほどける紙の円筒の陰影がピクセルごとに計算され、ロールの半径が縮むレシートロールの場面

ロールの半径は radiusAt が半径の二乗から THICK × ほどけた長さ を引いて求めるため、一周期2.6秒が終わるときには58pxから40.2pxになり、0.4 × ROLL_R² の下限に届くとそれ以上は縮みません。340×118のバッファのピクセルごとに円筒の法線 (0, ny, √(1 − ny²)) と光源の内積を取り、反射光 0.25 × (N·H)^26 を足して明るさを出しますが、THICK の4.8は実際の紙の厚さではなく、半径の変化が目に見えるように大きくした値です。このロールの場面は累積面積32.6%・強度51.3・23/23フレームと測定されました。

ロールからほどける紙の円筒の陰影がピクセルごとに計算され、ロールの半径が縮むレシートロールの場面
新しいタブで開く

06 · ロールからほどける紙の円筒の陰影がピクセルごとに計算され、ロールの半径が縮むレシートロールの場面 — 計測 累積面積 32.6% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

ROLL_RLIGHT_VECUNWIND_PXSHADE_GAIN
    // ほどけた紙の長さからロールの半径を求める — 面積(半径の二乗)が長さに比例して減る
    function radiusAt(len) {
        return Math.sqrt(Math.max(ROLL_R * ROLL_R * 0.4, ROLL_R * ROLL_R - THICK * len));
    }
    // 一ピクセルの陰影 — 円筒の法線(0, ny, nz)と光の向きの内積
    function shadePixel(ny) {
        const nz = Math.sqrt(Math.max(0, 1 - ny * ny));
        const diff = Math.max(0, ny * ly + nz * lz);
        const hx = lx;
        const hy = ly;
        const hz = lz + 1;
        const hl = Math.sqrt(hx * hx + hy * hy + hz * hz);
        const spec = Math.pow(Math.max(0, (ny * hy + nz * hz) / hl), 26);
        return 0.42 + SHADE_GAIN * diff + spec * 0.25;
    }

07熱が当たった所だけ濃くなり、時間とともにノイズマップに沿ってまだらに褪せる感熱紙の文字

印字ヘッドが700msかけて上から下へ通り、文字の場所には HEAT_GAIN 1の熱を、何もない紙にはしきい値 CUTOFF 0.25より低い HALO 0.24だけを積んで、そのあと点ごとに decayAt(n) の分だけ熱を引いていきます。地図の値 n は NOISE_SCALE 30の大きな塊0.7と細かな模様0.3を混ぜた値ノイズなので、熱1.0がしきい値を下回るまでの時間は最も早い場所の0.58秒から最も遅い場所の1.53秒まで開き、そこからまだらが生まれます。280×300の点を毎ティック回すこの場面は累積面積40.5%・強度30.7で、褪せきったあと休む区間があるため動いたフレームは18/23です。

熱が当たった所だけ濃くなり、時間とともにノイズマップに沿ってまだらに褪せる感熱紙の文字
新しいタブで開く

07 · 熱が当たった所だけ濃くなり、時間とともにノイズマップに沿ってまだらに褪せる感熱紙の文字 — 計測 累積面積 40.473% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

HEAT_GAINFADE_RATENOISE_SCALECUTOFF
    // 値ノイズ — 格子点のハッシュ四つをなめらかに混ぜる
    function noise2(x, y) {
        const xi = Math.floor(x);
        const yi = Math.floor(y);
        const fx = smooth(x - xi);
        const fy = smooth(y - yi);
        const a = hash2(xi, yi);
        const b = hash2(xi + 1, yi);
        const c = hash2(xi, yi + 1);
        const d = hash2(xi + 1, yi + 1);
        return lerp(lerp(a, b, fx), lerp(c, d, fx), fy);
    }
    // 一か所の褪せる速さ(毎秒の熱の減少量) — 地図の値 n(0〜1)が大きいほど早く褪せる
    function decayAt(n) {
        return FADE_RATE * (1 + SPREAD * n);
    }
    // 褪せる速さの地図 — 場所ごとに違う値(大きな塊 + 細かな模様)
    function buildRate() {
        for (let y = 0; y < H; y += 1) {
            for (let x = 0; x < PW; x += 1) {
                const n = 0.7 * noise2(x / NOISE_SCALE, y / NOISE_SCALE) + 0.3 * noise2(x / (NOISE_SCALE * 0.35) + 9, y / (NOISE_SCALE * 0.35) + 4);
                rate[y * PW + x] = n;
            }
        }
    }

08差し棒に並んで掛かった九枚のレシートが、長さの違う振り子として揺れ、短いものから静まる保管の場面

板九枚の長さを70pxから22pxずつ伸ばして246pxまで並べ、角加速度 −(GRAV / L)·sin θ − DAMP·(LEN_REF / L)·ω で揺らすので、短い板ほど速く揺れて早く静まります。1,800msごとに向きを変える衝撃を長さに反比例させて与えると、最も短い板(周期0.88秒)は約3.2秒、最も長い板(周期1.64秒)は約8.3秒たたないと止まらないため、長い板が落ち着く前に次の衝撃が入ってきます。累積面積は41.1%で九場面のうち最も広く動き、強度は83.4、フレームは23/23です。

差し棒に並んで掛かった九枚のレシートが、長さの違う振り子として揺れ、短いものから静まる保管の場面
新しいタブで開く

08 · 差し棒に並んで掛かった九枚のレシートが、長さの違う振り子として揺れ、短いものから静まる保管の場面 — 計測 累積面積 41.086% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

SHEET_NLEN_MINDAMPKICK
    // 板一枚の角加速度 — 長いほど遅く、短い板ほど空気抵抗が大きい
    function accel(i, a, v) {
        return -(GRAV / len[i]) * Math.sin(a) - DAMP * (LEN_REF / len[i]) * v;
    }
    function kick(dir) {
        for (let i = 0; i < SHEET_N; i += 1) {
            vel[i] += dir * KICK * (LEN_REF / len[i]);
        }
        barVel += dir * 60;
    }
    function step() {
        clock += FIXED_DT;
        const dt = FIXED_DT / 1000;
        if (clock >= nextKick) {
            kick(sign);
            sign = -sign;
            nextKick = clock + KICK_MS;
        }
        for (let i = 0; i < SHEET_N; i += 1) {
            vel[i] += accel(i, ang[i], vel[i]) * dt;
            ang[i] += vel[i] * dt;
        }
        barVel += (-90 * barShift - 9 * barVel) * dt;
        barShift += barVel * dt;
    }

09品目を人ごとの枠へドラッグして置くと、枠ごとの合計が減衰しながら新しい値を追って動く割り勘リスト

チップを放すと judge がチップの中心が三つの枠のどの四角形の中にあるかを調べ、中ならその枠へ、外なら元の所属へ、SPRING_K 240・減衰19のバネをかけて transform だけで動かします。枠の合計は round(小計 × (1 + TAX_RATE)) を目標に毎フレーム 1 − exp(−EASE_RATE·dt) の割合ずつ近づくので、食パン一つを受け取った枠の3,080は256msで90%、512msで99%まで満ちます。キャンバスを使わずボタン五つが動くだけでも、累積面積37.7%・強度138.5・23/23フレームが出ました。

品目を人ごとの枠へドラッグして置くと、枠ごとの合計が減衰しながら新しい値を追って動く割り勘リスト
新しいタブで開く

09 · 品目を人ごとの枠へドラッグして置くと、枠ごとの合計が減衰しながら新しい値を追って動く割り勘リスト — 計測 累積面積 37.728% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

SLOT_NTAX_RATESPRING_KEASE_RATE
    // 放したときの判定 — チップの中心がどの枠の中にあるか
    function judge(cx, cy) {
        for (let s = 0; s < SLOT_N; s += 1) {
            const r = slotRect(s);
            if (cx >= r[0] && cx <= r[0] + r[2] && cy >= r[1] && cy <= r[1] + r[3]) {
                return s;
            }
        }
        return -1;
    }
    function drop(i) {
        const s = judge(px[i] + CHIP_W / 2, py[i] + CHIP_H / 2);
        if (s < 0) {
            status.textContent = labelOf(i) + ' — ' + TXT_MISS;
            if (reduceQuery.matches) {
                snap();
            }
            return;
        }
        assign(i, s);
    }

送り距離・ちぎれる基準・褪せる速さ — 値ひとつで紙の手触りが変わる場所

この節の数値はレンダーの撮影ではなく、場面のソースにある rowY・intactTeeth・decayAt・buildRate・buildBars をそのまま呼び出して計算した結果です。上の測定表とは測り方が違うので、二つを混ぜて読まないでください。

FEED_PX を14から30に上げると、見出しの行が紙の上へ抜けていきます

九行を印字しきった瞬間、先頭行の y は101から−43へ、最終行の y は213から197へ移り、先頭行と最終行の距離は112pxから240pxに開きます。画面の中に残る行は9行から7行になり、店名と最初の区切り線が上へ消えていきます。震えの最大値の約2.2pxは変わらないので、行の間隔に対する震えの割合は14pxのときの半分を下回ります。

TEAR_PX を60から140に上げると、96pxの引きが戻ってくるようになります

自動デモの偶数回目の引きである96pxは、60では基準を超えて切片が落ちますが、140では判定を越えられずにバネで戻ります。96pxの時点でつながっている歯は21本中0本から9本に増え、130pxまで引いても3本が残り、36pxの引きで残る歯は14本から21本すべてになります。歯がちぎれる閾値も TEAR_PX に比例しているので、この値ひとつが紙の粘りとちぎれる判定を一緒に動かします。

FADE_RATE を0.4から1.6に上げると、褪せる速さが約4倍になります

熱1.0が CUTOFF を下回るまでの時間の中央値は0.86秒から0.22秒に、最も遅い場所は1.53秒から0.38秒に、最も早い場所は0.58秒から0.15秒になります。ノイズの地図の形は変わらないので、まだらが出る場所と割合は同じまま、時間の軸だけが4分の1に畳まれます。自動の周期 CYCLE_MS の2.8秒はそのままなので、同じ周期の中で褪せきった白い紙のまま止まっている時間がそのぶん長くなります。

バーコードのシードを461から462に変えると、棒の数と数字が一緒に入れ替わります

シード461ではインクの列が154本で数字は 472925035412、「次のコード」を押してシードが462になると149本で 489092530559 が出ます。同じシードで二度作ると、棒も数字もまったく同じものがもう一度出てきます。数字は lcg(seed + 7) で棒とは別の数列から引いているため、棒の幅と数字は互いに対応していません。

基準の手前で放すとき・目標を行き過ぎるとき — レシートの場面が壊れるところ

紙を手で触る場面には、見えているものと判定がずれる隙間ができます。以下はソースの式を計算し直すか入力の経路をたどって確かめたもので、機器ごとに一フレームにかかる時間は測っていません。

02は歯がほとんど切れていても、59pxで放すと戻ってきます

歯の切れ方は引いた距離に応じて一本ずつ計算されますが、ちぎれる判定は手を放した瞬間の pull ≥ TEAR_PX 一回だけです。そのため59pxで放すと紙がほとんど離れて見えるのに、バネが512msかけて巻き戻し、反対側へ3.6px行き過ぎてから止まります。36pxで放したときは496ms・2.2pxで、96pxで放した切片は384msで画面の下へ消えます。

02のキーボード操作は、Enter を押すまで手を放しません

キャンバスにフォーカスを置いて ArrowDown を押すと12pxずつ引かれ、ArrowUp は12px戻しますが、キーを離すだけでは放されず、Enter を押して初めて判定が下ります。新しい紙が滑り出ているあいだは、紙をつかめません。人が一度触ったあとは自動デモが再読み込みまで回らず、05と09も人が手を出すと自動の進行が止まります。

05の合計は、一瞬だけ目標より大きな数字を見せます

慣性で追いかけるため、合計は目標を4.2%超えます。11,600へ向かうときは最高で約12,090まで上がってから下ります。1,200へ向かう合計は1,648msで初めて±0.5の幅に入り、2,368ms以降はその幅の中にとどまり、13,500へ向かうときは同じ二つの時点が2,592msと3,184msです。合計が1,200のとき、税の行は1,264msで初めて目標の±0.5に入り、1,584ms以降はそこから出ません。実際の金額を見せる場所なら転がっている途中の数字も画面に出ることを計算に入れ、数字盤が5桁なので99,999を超えると先頭の桁が切れることも覚えておいてください。

05の税の末尾の桁は、半分かかったまま転がります

税は四捨五入していない補間値をそのまま数字盤に渡しています。そのため値が止まる直前まで、最後の桁が二つの数字のあいだに半分かかって見えます。きっちりした数字で止まって見せたいなら、数字盤に渡す前に値を整数にそろえる一行がもう一つ要ります。

03はドラッグと方向キーで光の角度の範囲が違います

自動で揺れるとき光は−8°〜84°を往復しますが、キャンバスをドラッグして決める角度は±60°で切られ、方向キーは6°ずつ±90°まで動きます。そのためドラッグでは自動の範囲の84°側の端を作り直せず、方向キーなら自動では出てこない−90°まで傾けられます。

06の光源を端まで寄せると、暗い側が底に張りつきます

陰影は 0.42 + SHADE_GAIN × max(0, N·L) なので、光を受けない面は0.42より下がらず、端のふた側にはそこへさらに最大0.55が掛かります。光源を片方の端へ寄せると、反対側がこの底の値に張りついてほとんど黒く潰れます。「停止」を押してもロールが止まるだけで、ポインターを動かせば光源は変わり続けます。

07の白い紙の熱をしきい値より上げると、しみが文字のように残ります

何もない紙に積む HALO の0.24は CUTOFF の0.25のすぐ下なので、ほのかに温まるだけで文字の色にはなりません。この値を0.25以上に上げると、文字のない場所にも文字色のしみが出てきます。ピクセルのループは毎ティック84,000点を回すため、処理の遅い機器ではフレームが落ちることがあります。

08は揺れる角度に上限がありません

復元力は sin θ だけで、角度を縛る clamp がありません。「ぽんと叩く」ボタンやキャンバスを押すと、userKick() が自動の衝撃と同じ kick(sign) を呼んだあとに符号を反転させるので、続けて押しても片側にだけたまらず、左右が交互に入ります。その代わり押すたびに次の自動の衝撃がその時点から1.8秒後へずれるため、押し続けているあいだは自動の予定が回ってきません。

画面が狭いと、09のチップの文字も一緒に小さくなります

09は480×300の板全体に transform: scale(親の幅 / 480) をかけて合わせています。撮影ウィンドウ480×300では舞台の外側の余白のため舞台が約418.6×261.6pxで描かれ、倍率は約0.872、12pxのチップの文字は約10.5pxに見えます。スマートフォンのように幅がさらに狭いと文字もその比率で縮み、340px以下では状態の表示とボタンの文字も10pxに下がります。

タブを隠して戻ると、そのあいだの時間は捨てられます

タブが隠れると requestAnimationFrame が止まり、戻った最初のフレームには100msだけが足されます。そのため紙が数秒分を一気に取り戻して跳ねることはありませんが、隠していたあいだに進むはずだった印字や褪せは飛ばしたまま続きます。

定数を一つずつ変えて、紙がどこで崩れるのかを自分の目で確かめたいなら、zip をダウンロードして fpfbfa6s で解凍してください。中には九つの場面の index.html と style.scss、それに同じ計算を移した React 版が一緒に入っています。

行送り・ちぎれる基準・褪せる速さの三つの変形と、レシート場面のアクセシビリティ

名前 値 感じ
印字の行送り距離を変える 01 FEED_PX 14 → 30 先頭行から最終行までの距離が112pxから240pxに開き、見出しの行が y −43へ押し出されて、見える行が9行から7行になります。震えの最大値の約2.2pxは同じです。
ちぎれる基準を変える 02 TEAR_PX 60 → 140 自動デモの96pxの引きが落下からバネの戻りに変わり、その瞬間につながっている歯が0/21から9/21に増えます。
褪せる速さを変える 07 FADE_RATE 0.4 → 1.6 しきい値を下回るまでの時間の中央値が0.86秒から0.22秒になり、まだらの形は同じまま約4倍速く褪せます。

操作はマウスや指がなくても届きます。場面ごとに本物の <button> があり(01 もう一度印字、02 ちぎる・軽く引く、03 光を固定、04 もう一度読み取る・次のコード、05 品目を追加・空にする、06 停止・新しいロール、07 もう一度印字・日光に当てる、08 ぽんと叩く、09 最初に戻す)、03の「光を固定」・06の「停止」・07の「日光に当てる」は aria-pressed でオンの状態を伝えます。キーを直接受けるキャンバスは02・03・06の三つにだけ tabindex="0" があり、09は品目のチップ五つがボタンなので、Tab で順にたどれます。09のチップで Enter か Space を押すと元の位置 → A → B → C の順に移り、数字の1〜3はその枠へ、0か Backspace は元の位置へ戻します。preventDefault がかかっているのは02の ArrowDown・ArrowUp・Enter、03の左右の方向キー、06の方向キー、09の数字キーと削除キーだけで、Tab はどの場面でも止めていません。

スクリーンリーダーに伝える文言は、場面ごとに aria-live="polite" の状態表示が一つだけで、出来事があったときにだけ変わります。01は一回の印字で 印字中 と 印字完了 — 合計 11,600 の二回、08は人が叩いたときだけ 叩いた を知らせて自動の衝撃は知らせず、09は 食パン → B, 合計 3,080 の形で、どの枠がいくらになったかを読み上げます。

prefers-reduced-motion がオンなら、01〜08は起動時にそれを確かめてフレームのループを回さず、settle() と draw() を一度ずつ呼んで止まった画面を残します。01は印字しきったレシート、04は確認の点が灯った読み取り済みの状態、07は文字がすべて濃いままの状態で止まっています。入力は結果へそのまま飛びます。02は finishNow() でバネも落下もなく終わりの状態になり、05は shown = target で数字をすぐに置き、08は揺らさずに状態の文字だけを変え、09は snap() でチップを転がさずにその場へ座らせます。スタイルシート側の reduce のブロックがしているのは、カーソルを標準の形に戻すことだけです。

css receipt effect を CSS だけで作れるのはどこまでか?

この記事で CSS が受け持ったのは舞台とボタンです。.rc は width: min(92vw, calc(160vh - 12.8vw)) と aspect-ratio: 480 / 300 で大きさだけを決め、02・03のキャンバスと09のチップには、ドラッグがページのスクロールに漏れないよう touch-action: none をかけています。その内側にある css receipt effect は、放した瞬間の判定(02)、目標を追って行き過ぎる数字(05)、バネで行き過ぎてから戻るチップ(09)、点ごとに速さが違う褪せ(07)のように、入力と前のフレームで次の値が決まるものばかりで、キーフレームに前もって書いておける動きではありませんでした。

止まったレシートカードだけが必要なら、クーポンの縁を扱ったクーポンデザインの記事が近く、決済の流れ全体はカートと決済UIの記事、数字が増えていく演出だけを見たいならCSSの数字カウンターの記事へ続けて読んでみてください。時間の処理とドラッグの入力は、requestAnimationFrame と Pointer events の文書を基準に組みました。

html css receipt template として持ち帰る前の、よくある質問三つ

css receipt design のまま決済画面に入れると、金額は正しく出ますか?

金額の計算は場面を見せるための例です。09の枠の合計は round(小計 × (1 + TAX_RATE)) で一度丸めるだけで、05の税の行は丸めずに転がります。税率と丸めの規則は、使う側のサービスの規則に置き換えてください。

レシートに印字される文字はどこで変えますか?

01は ROWS の配列、06・07は TXT_LINES、05は品目の価格 PRICES を直せば変わります。07は起動時に文字のマスクを一度だけ描いて読み取るので、文字を変えたあとはページを読み込み直さないと、褪せの地図に反映されません。

場面を一つだけ切り出して使えますか?

使えます。場面のフォルダーごとに index.html と、その横にあるコンパイル済みの style.css の二つのファイルがあれば動き、ライブラリや CDN は呼びません。style.scss はサイト共通のトークンファイルを @use で読むので、scss からコンパイルし直すならトークンの値も一緒に移す必要があり、状態の表示とボタンのまとまりは舞台 .rc を基準に置かれているため、舞台のクラスと比率も合わせて持っていくほうが安全です。

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

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