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

テキストエリア 文字数カウント 9パターン — 書記素で数えて上限をリングで見せる

文字数はlength一行で済みそうだが、絵文字が2と数えられ、変換中は数字がちらつく — 01番と04番がその二か所を一つずつ塞ぐ。テキストエリア 文字数カウンターは、入力が変わるたびに人が見る一文字ぶんを数え、残りの上限を知らせる数字です。

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

テキストエリア 文字数カウント 9パターン — 書記素単位の計算が数字を正す

九つは、数字が間違えやすい順に並べました。01 は絵文字が一文字として数えられる基本の計算、02 はその数字を上限に対するリングへ変える場面、03 は全角と半角で幅が違うときの計算です。04 は変換中に数字がちらつく問題、05 は超えた文字だけを塗るミラーレイヤー、06 は貼り付けが上限を超える瞬間を扱い、07 は文字・単語・バイト・読む時間を一度に測り、08 は残り文字の案内と色が区間ごとに続けて変わる仕組み、09 はスクリーンリーダーに読み上げさせる回数を減らす仕組みです。入力フォームに初めて付けるなら、01・02・08 の三つを合わせるだけでも、数字とリングと案内文が一つの入力欄で噛み合って動きます。

技法別に見る文字数計算のコード

九項目はどれも textarea 一つとその label、カウント要素を指す aria-describedby でできていて、下には数字を計算する中心の部分だけを抜き出しました。残りのマークアップとスタイルは、デモの下の zip にそのまま入っています。実演の文章はデモ用で、サーバーとは通信しません。入力欄を押すかキーを打つと実演は止まり、自分で書いた文字が同じ計算を通ります。

01書記素クラスタ単位で数えるリアルタイム文字数

graphemes が Intl.Segmenter で入力値を人が見る一文字ずつに分けて大きな数字に出し、横の「lengthでは」には ta.value.length を置いてあるので、絵文字が入った瞬間に二つの数字が割れ、ポスターの時点では書記素12字に対してlength 19です。文字数表示を初めてフォームに付けるなら、ここから始めます。01 は面積13.4%・強度平均64.8で、23フレーム中23フレームが動きました。

書記素クラスタ単位で数えるリアルタイム文字数
新しいタブで開く

01 · 書記素クラスタ単位で数えるリアルタイム文字数 — 計測 累積面積 13.419% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

STRIP_SLOTSPOP_MSCOUNT_LERP_RATEANNOUNCE_EVERY
  function update(nowMs) {
    var list = graphemes(ta.value);
    var count = list.length;
    var view = count <= STRIP_SLOTS ? list : list.slice(count - STRIP_SLOTS);
    for (var k = 0; k < STRIP_SLOTS; k += 1) {
      var g = view[k] || '';
      tiles[k].textContent = g;
      tiles[k].className = 'cc__tile' + (g ? ' is-on' : '') + (g.length > 1 ? ' is-multi' : '');
    }
    if (count > lastCount && !reduce) born[Math.min(count, STRIP_SLOTS) - 1] = nowMs;
    if (count % ANNOUNCE_EVERY === 0 && count !== lastCount && count > 0) {
      sr.textContent = '現在 ' + count + '文字';
    }
    lastCount = count;
    target = count;
    lenTarget = ta.value.length;
    if (reduce) {
      shown = target;
      lenShown = lenTarget;
      paintNumbers();
    }
  }

02使った文字数で満ちていくスプリングリング

使った文字数を上限で割った比率がリングの弧の長さになり、RING_SPRING_STIFFNESS と RING_SPRING_DAMPING のばねを通って目標を追い、80%からミントがアンバーへ、上限でピンクへ色がつながって変わります。上限を最初に超える瞬間は減衰する正弦波でリングが揺れるので、上限の手前を数字より先に目へ入れたいときに使います。02 は面積30.3%・強度平均108.5で、23フレーム中23フレームが動きました。

使った文字数で満ちていくスプリングリング
新しいタブで開く

02 · 使った文字数で満ちていくスプリングリング — 計測 累積面積 30.287% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

CHAR_LIMITRING_SPRING_STIFFNESSRING_SPRING_DAMPINGWARN_RATIOSHAKE_AMP
  function paint(nowMs) {
    var ratio = used / CHAR_LIMIT;
    var drawn = clamp(x, 0, 1);
    arc.style.strokeDasharray = (drawn * RING_LENGTH).toFixed(2) + ' ' + RING_LENGTH.toFixed(2);
    arc.style.stroke = colorAt(Math.min(x, 1.12));
    card.style.background = tintAt(Math.min(x, 1.12), 0.16);
    var remain = CHAR_LIMIT - used;
    countEl.textContent = String(remain);
    countEl.style.color = remain < 0 ? 'rgb(255,158,187)' : '';
    hint.textContent = remain >= 0 ? '残り ' + remain + '文字' : '超過 ' + (-remain) + '文字';
    var pulse = 1 + Math.max(0, x - 1) * 0.5;
    var dx = 0;
    if (shakeAt >= 0 && !reduce) {
      var age = nowMs - shakeAt;
      var amp = SHAKE_AMP * Math.exp(-age / SHAKE_DECAY_MS);
      dx = amp < 0.1 ? 0 : amp * Math.sin(age * SHAKE_OMEGA);
    }
    ring.style.transform = 'translateX(' + dx.toFixed(2) + 'px) scale(' + pulse.toFixed(3) + ')';
    return ratio;
  }

03全角2・半角1で数える幅の重み付きカウント

weightOf が書記素ごとにコードポイントの範囲から幅の重みを付け、半角は1、全角は2として足し合わせ、80幅の上限まで棒を伸ばします。実演の文は日本語の全角、英字、半角カナが混ざっているので、同じ文字数でも棒の長さが倍ずつ違う様子が見えます。03 は面積10.0%・強度平均150.6で、23フレーム中23フレームが動きました。

全角2・半角1で数える幅の重み付きカウント
新しいタブで開く

03 · 全角2・半角1で数える幅の重み付きカウント — 計測 累積面積 9.971% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

WIDTH_LIMITHALF_WEIGHTFULL_WEIGHTPOP_MS
  function graphemes(s) {
    if (!seg) return Array.from(s);
    return Array.from(seg.segment(s), function (x) { return x.segment; });
  }
  function weightOf(g) {
    var cp = g.codePointAt(0);
    if (cp <= 0x7e || (cp >= 0xff61 && cp <= 0xff9f)) return HALF_WEIGHT;
    return FULL_WEIGHT;
  }

04変換中の文字でも揺れないIME対応カウンター

compositionstart・input・compositionend の三つを onCompStart・onInput・onCompEnd が受け、変換中は「即時カウント」の数字だけが動き、確定の瞬間に「保留カウント」の数字を一度だけ更新します。デモは日本語入力の変換を同じハンドラへ流し込んだ再現で、実際の入力機を操作する場面ではありませんが、本物の入力も同じ経路を通ります。04 は面積1.9%・強度平均121.1で、23フレーム中23フレームが動きました。

変換中の文字でも揺れないIME対応カウンター
新しいタブで開く

04 · 変換中の文字でも揺れないIME対応カウンター — 計測 累積面積 1.882% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

STEP_MSPAUSE_MSBUMP_MSHOLD_MS
  function onCompStart() { composing = true; }
  function onInput() {
    rawCount = countGraphemes(ta.value);
    if (!composing) safeCount = rawCount;
  }
  function onCompEnd(nowMs) {
    composing = false;
    preEl.textContent = '';
    var next = countGraphemes(ta.value);
    if (next !== safeCount) bumpAt = nowMs;
    safeCount = next;
    rawCount = next;
    sr.textContent = '現在 ' + safeCount + '文字';
  }

05上限を超えた後ろだけを示すミラーレイヤー

ミラーの .cc__mirror と入力欄の .cc__ta は書体・13px/18pxの行の高さ・余白・枠・white-space: pre-wrap を一組にそろえ、上限より後ろの書記素だけを <mark> で塗ります。入力欄の背景は透明、ミラーの文字色も透明なので蛍光の塗りだけが透けて見え、スクロールは scroll イベントと更新のたびに mirror.scrollTop = ta.scrollTop で合わせます。05 は面積14.4%・強度平均117.8で、23フレーム中22フレームが動きました。

上限を超えた後ろだけを示すミラーレイヤー
新しいタブで開く

05 · 上限を超えた後ろだけを示すミラーレイヤー — 計測 累積面積 14.367% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

MIRROR_LIMITFLASH_MSCHUNK_MS
  function update(nowMs) {
    var list = graphemes(ta.value);
    var over = Math.max(0, list.length - MIRROR_LIMIT);
    mirror.textContent = '';
    mirror.appendChild(document.createTextNode(list.slice(0, MIRROR_LIMIT).join('')));
    mark = null;
    if (over > 0) {
      mark = document.createElement('mark');
      mark.className = 'cc__mark';
      mark.textContent = list.slice(MIRROR_LIMIT).join('');
      mirror.appendChild(mark);
    }
    mirror.appendChild(document.createTextNode('​'));
    syncScroll();
    countEl.textContent = String(list.length);
    countEl.style.color = over > 0 ? '#9a2b52' : '';
    overEl.textContent = over > 0 ? '超過 ' + over + '文字' : '';
    overEl.className = 'cc__over' + (over > 0 ? ' is-on' : '');
    if (over > lastOver && !reduce) flashAt = nowMs;
    lastOver = over;
    var next = over > 0 ? 1 : 0;
    if (next !== stage) {
      sr.textContent = next === 1 ? '上限を超えました' : '';
      stage = next;
    }

06貼り付けが溢れたら切り詰めて減衰振動で知らせる

paste イベントは preventDefault したあと applyPaste に渡され、残りの枠(80から選択範囲を除いた書記素数を引いた数)ぶんの先頭だけを受け取って、残りは捨てます。切り詰めた瞬間は入力欄が減衰する正弦波で左右に揺れ、ピンクの枠が一緒に沈み、「切り捨て」のトーストが1.5秒だけ出ます。06 は面積12.0%・強度平均110.3で、23フレーム中22フレームが動きました。

貼り付けが溢れたら切り詰めて減衰振動で知らせる
新しいタブで開く

06 · 貼り付けが溢れたら切り詰めて減衰振動で知らせる — 計測 累積面積 12.012% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

PASTE_LIMITSHAKE_AMPSHAKE_DECAY_MSSHAKE_OMEGATOAST_MS
  function applyPaste(text, nowMs) {
    var start = ta.selectionStart;
    var end = ta.selectionEnd;
    var before = ta.value.slice(0, start);
    var after = ta.value.slice(end);
    var room = PASTE_LIMIT - graphemes(before + after).length;
    var incoming = graphemes(text);
    var take = incoming.slice(0, Math.max(0, room)).join('');
    ta.value = before + take + after;
    ta.setSelectionRange(start + take.length, start + take.length);
    refresh();
    if (incoming.length > room) signal(incoming.length - Math.max(0, room), nowMs);
  }

07文字・単語・バイト・読む時間を一度に測るメーター

文字は書記素の数、単語は Intl.Segmenter の単語単位で isWordLike なものの数、バイトは TextEncoder が出すUTF-8の長さで、読む時間は文字数を READ_CHARS_PER_MIN の500で割って秒に直した値です。四つの数字は指数補間で同時に転がり、タイルの背景と下線の棒は上限に対する比率で満ちていきます。07 は面積23.0%・強度平均127.8で、23フレーム中23フレームが動きました。

文字・単語・バイト・読む時間を一度に測るメーター
新しいタブで開く

07 · 文字・単語・バイト・読む時間を一度に測るメーター — 計測 累積面積 22.963% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

READ_CHARS_PER_MINNUMBER_LERP_RATEANNOUNCE_EVERYBAR_CAPS
  function measure(text) {
    var chars = gSeg ? Array.from(gSeg.segment(text)).length : Array.from(text).length;
    var words = wSeg
      ? Array.from(wSeg.segment(text)).filter(function (x) { return x.isWordLike; }).length
      : text.split(/\s+/).filter(Boolean).length;
    var bytes = enc.encode(text).length;
    var secs = Math.ceil(chars / READ_CHARS_PER_MIN * 60);
    return [chars, words, bytes, secs];
  }

08しきい値ごとに文言と色がなめらかに変わる残り文字の案内

色は0〜0.8がミント、0.8〜1がミントからアンバー、1〜1.15がアンバーからピンクへ線形に補間され、同じ色が残りの数字・案内文・棒・行の背景に使われます。残りの数字は1倍から1.8倍までふくらみ、案内文は区間が変わるときだけ220msのフェードで入れ替わります。08 は面積20.2%・強度平均103.7で、23フレーム中23フレームが動きました。

しきい値ごとに文言と色がなめらかに変わる残り文字の案内
新しいタブで開く

08 · しきい値ごとに文言と色がなめらかに変わる残り文字の案内 — 計測 累積面積 20.176% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

HINT_LIMITWARN_RATIOMAX_SCALEOVER_SPANMSG_FADE_MS
  function update(nowMs) {
    used = countGraphemes(ta.value);
    var ratio = used / HINT_LIMIT;
    var next = ratio >= 1 ? 2 : (ratio >= WARN_RATIO ? 1 : 0);
    if (next !== stage) {
      stage = next;
      var text = next === 2 ? '上限を超えました' : (next === 1 ? '上限が近いです' : '余裕があります');
      msgEl.textContent = text;
      sr.textContent = next === 0 ? '' : text;
      swapAt = nowMs;
    }
    if (reduce) { shownUsed = used; paint(nowMs); }
  }

09読み上げの連発を防ぐaria-liveの間引きと動き軽減

ANNOUNCE_RATIOS の 0.8 と 1 を初めて超える瞬間だけ .cc__sr に文言を入れ、入力のたびに読み上げることはしません。画面の「毎回読み上げ」の行は文字数が変わるたびに増える演示用の回数で、実際の aria-live には入れていません。09 は面積4.7%・強度平均138.0で、23フレーム中23フレームが動きました。

読み上げの連発を防ぐaria-liveの間引きと動き軽減
新しいタブで開く

09 · 読み上げの連発を防ぐaria-liveの間引きと動き軽減 — 計測 累積面積 4.685% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

ANNOUNCE_LIMITANNOUNCE_RATIOSTICK_SLOTSNUMBER_LERP_RATEPOP_MS
    if (len !== lastLen) {
      rawCount += 1;
      addTick(rawList, rawCount, nowMs);
    }
    var ratio = len / ANNOUNCE_LIMIT;
    for (var i = 0; i < ANNOUNCE_RATIOS.length; i += 1) {
      if (!passed[i] && ratio >= ANNOUNCE_RATIOS[i]) {
        passed[i] = true;
        safeCount += 1;
        addTick(safeList, safeCount, nowMs);
        sr.textContent = i === 1 ? '上限を超えました' : '上限が近いです';
      }
    }
    lastLen = len;

値が場面を変える場所 — 上限・リングのばね・警告の比率

リングがどれだけ速く満ち、いつ色が変わるかは、項目の数ではなく、この表の数値と三つの定数が決めます。

項目 面積 強度平均 動いたフレーム
01 書記素クラスタの文字数 13.4% 64.8 23/23
02 スプリングリング 30.3% 108.5 23/23
03 幅の重み付きカウント 10.0% 150.6 23/23
04 IME対応 1.9% 121.1 23/23
05 ミラーレイヤー 14.4% 117.8 22/23
06 貼り付けの切り詰め 12.0% 110.3 22/23
07 メーター 23.0% 127.8 23/23
08 残り文字の案内 20.2% 103.7 23/23
09 読み上げの間引き 4.7% 138.0 23/23

03 は9.97%で10%の直前、04・09 はそれより低く、いずれもコンポーネントの記事ではごく普通のことです。04 は数字二つと点だけなので面積は小さくても変換の段階ごとに値が変わって23フレームすべてで動き、09 は目盛りが数個増えるだけです。ポスターは打ち込みの場面なので、02 の揺れや 04 の数字の跳ねは動くデモで確かめてください。テキストエリア 文字数制限の見せ方は、CHAR_LIMIT がリングの満ちて揺れる文字数を、RING_SPRING_STIFFNESS がリングが目標を追う速さを、WARN_RATIO がアンバーの始まる比率を決めます。02 の減衰比は14を 2√220 で割った約0.47で、階段応答のオーバーシュートは約19%になります。

どこで壊れるか — 絵文字・変換中の入力・貼り付けの連発

まずフォールバックが効きます。01 の graphemes は Intl.Segmenter がなければ Array.from でコードポイント単位に分けるので、家族の絵文字は人三人と結合子二つに割れ、国旗は二つ、結合文字の付いた café は五つになり、この記事の核の場面が消えます。03 の weightOf は書記素の最初のコードポイントだけを見て 0x7e を超えるとすべて幅2と数えるので、ハングルもアクセント付きのラテン文字も絵文字も同じ2です。幅の上限を使うサービスは、そのサービスが決めた幅の規則をこの関数へそのまま移す必要があります。

06 は paste の経路と input の経路が別です。貼り付けは setSelectionRange でキャレットを貼り付けた末尾に置きますが、タイプで上限を超える場合を受ける input の経路は、value を80書記素まで切って代入し直すだけで、キャレットを戻す行がありません。デモの最初の貼り付けでは5文字だけが切り捨てられます。05 のミラーは入力欄と書体・余白・行の高さが一組で、入力欄のスクロールバーを隠して幅をそろえた構造なので、入力欄だけ書体や余白を変えると塗った位置がずれます。01 の文字の帯は STRIP_SLOTS の16マスまでしか描かず、それより後ろは最後の16文字だけを見せます。04 は日本語入力の変換を再現したもので、実際の入力機の操作を録ったものではない点も覚えておいてください。この限界まで含めた九つのソースをそのまま開いて確かめるには、ダウンロードした zip を解凍するときにパスワード jg6vwkst を入力してください。

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

項目 定数 値 変わり方
02 CHAR_LIMIT 120 → 60 上限を下げると同じ144文字の実演でリングが半分の長さで満ち、アンバーは96文字目ではなく48文字目に、ピンクの揺れは60文字目に始まります
02 RING_SPRING_STIFFNESS 220 → 110 減衰14はそのままで剛性を下げるとリングが目標をゆっくり追い、減衰比が0.47から0.67に上がってオーバーシュートは約19%から約6%に減ります
08 WARN_RATIO 0.8 → 0.9 100文字の上限でアンバーと「上限が近いです」の案内が80文字目から90文字目へ遅れ、余裕の帯は10文字長く、注意の帯は20文字から10文字に縮みます

prefers-reduced-motion: reduce の分岐は matchMedia で九項目すべてが調べていて、01〜08 は動きを減らす設定の訪問者には requestAnimationFrame のループを始めず、実演の文章をすぐに満たして最終の状態を一度描き、そのあとの入力は補間なしでその場で描きます。09 はOS設定に加えて、自分の「動きを減らす」ボタン(aria-pressed)でも同じ状態に切り替わります。キーボードで届くのは、01〜08 では入力欄だけで、09 はボタンもあるので Tab で順に届き、数字は入力のたびに変わるため aria-live="polite" は数字ではなく隠した状態の行 .cc__sr の一か所だけにあって、区間に入ったときや、01 の10文字ごと・07 の50文字ごとのようなまれな瞬間にだけ文言を入れます。入力欄は label とつながり、aria-describedby でカウント要素を指します。

文字数をlengthで数えるとなぜ違うのか?

ta.value.length は人が見る文字ではなく内部のコード単位の数なので、絵文字や結合文字が入ると実際の文字より大きな値になります。01 が二つの数字を並べて置いたのはそのためで、javascript テキストエリア 文字数カウントを自分で書くなら、まず Intl.Segmenter の MDN の説明で書記素の単位を確かめるのが近道です。変換中の数字は compositionstart と input イベントの順序を踏まえて扱います。この計算の周りに付ける部品を選ぶなら、入力まわりの記事へ続けてください。チップ入力は タグ 入力 ui 9パターン、入力欄そのものの形は 入力欄 デザイン 9選 — コピペで動くフォーム部品、貼り付けのフィードバックは コピー ボタン html UI 9選 — コード・URL・テキストのフィードバックまで、入力値の見せ方は パスワード 目のアイコン 9パターン — 文字とドットの入れ替え計算 で読めます。

文字数カウンターを使う前によくある質問

html テキストエリア 文字数 制限は、超えたら入力を止めるべきですか

この九つは、ほとんどが止めずに知らせます。02・05・08 は超えた文字をそのまま残してリング・蛍光・色で示すだけで、06 だけが貼り付けが上限を超えたときに先頭を受け取って残りを捨てます。止めるか表示だけにするかはサービスが決めることなので、カウンターは計算と合図だけを受け持ち、処理はフォーム側に置くほうが直しやすくなります。

lengthの一行だけで文字数を表示してはいけませんか

英字と数字しか入らない入力なら足りますが、絵文字や結合文字が入りうるなら、01 のように Intl.Segmenter で書記素の単位を数えるほうが人が見る数字と合います。そのオブジェクトがない環境に備えて Array.from のフォールバックを置いておきますが、フォールバックでは絵文字が割れる限界を知って使ってください。

単語数や読む時間も一緒に見せたほうがよいですか

文字の上限が目的なら 02 か 08 のどちらか一つで足ります。書き物のツールなら、07 のように四つの数字を一行に並べるのが自然です。読む時間は READ_CHARS_PER_MIN で割っただけの値なので、サービスの文章の性格に合わせてこの定数一つを変えれば済みます。

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

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