本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100
フォーム

入力欄の文字数カウンターの輪が満ち、上限に近づくと色が変わる

デモ

操作:クリック。 「さわってみる」を押すと自動再生が止まり、マウスで動かせます。

仕様

用途
フォーム
動作
DOM
種類
レシピ
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

欄に打つたびに字数を数え直し、欄の下の輪を上限に対する割合まで満たします。輪は SVG の円を 2 本重ねたもので、上の円の stroke-dasharray を割合で書き換えます。円に pathLength を 100 と付けておくと、割合をそのまま線の長さとして書けるので、円周の計算が要りません。残りが 10 字以下になると輪が注意の色に変わって少し膨らみ、中に残りの字数が出ます。上限を超えるとエラーの色になり、数字はマイナスで超えた分を示して、送信ボタンを押せなくします。

輪は、打つたびに今の位置から次の割合へ 0.35 秒の power2.out で満ちます。かな漢字変換で何字かまとめて確定しても、輪が跳ばずに伸びて見えます。膨らむのは半径 12px から 19px までで、back.out で少し行き過ぎてから収まるので、残りが少なくなった瞬間に目が向きます。注意の色は赤の手前の橙にし、中の数字は小さな文字なので、同じ系統で背景に対して読める濃さまで暗くしています。

字数は、文字列の長さではなく、見た目の 1 字(書記素)で数えます。長さで数えると、絵文字などを 2 字と数えてしまうので、Intl.Segmenter で区切ります。また、maxlength で打てなくすると、貼り付けた文の後ろが黙って切れてしまいます。上限を超えても打てるようにしておき、送信だけを止めて、どこを削るかを書いた人に選んでもらいます。

輪と数字は目で見るためのもので、読み上げからは外します。上限は欄の説明として最初に伝え、残りの字数は aria-live の領域に節目(残り 10 字以下・5 字以下・ちょうど上限・超えたとき)だけ書きます。打つたびに書くと、読み上げが打鍵に追いつきません。色だけに頼らないよう、超えたら数字にマイナスを付けて欄を aria-invalid にし、送信ボタンは焦点を受けられるよう aria-disabled で止めます。動きを減らす設定では、補間せずに切り替えます。

つまみは、上限の字数、注意に切り替える残りの字数、輪の大きさの 3 つです。上限が 140 字のように長い欄なら、注意に切り替えるのは残り 20 字あたりからが目安です。SNS の投稿やレビュー、プロフィールの自己紹介、お問い合わせの件名のように、字数に決まりのある欄に向いています。

コード

HTML
<!-- 上限は data-limit、輪を注意の色にして残りの字数を出し始める所は data-warn(残りの字数)で決める -->
<form class="note" action="/feedback/" method="post" data-limit="60" data-warn="10">
  <label class="note__label" for="note-message">ひとことメッセージ</label>
  <textarea class="note__field" id="note-message" name="message" rows="3" placeholder="ご感想をどうぞ"
            aria-describedby="note-limit"></textarea>
  <div class="note__footer">
    <p class="note__hint" id="note-limit">60字まで</p>
    <!-- 輪と数字は目で見るためのもの。読み上げには、下の aria-live の領域で節目だけを伝える -->
    <div class="counter" aria-hidden="true">
      <svg class="counter__ring" viewBox="0 0 48 48">
        <circle class="counter__track" cx="24" cy="24" r="12" />
        <circle class="counter__bar" cx="24" cy="24" r="12" pathLength="100" />
      </svg>
      <span class="counter__number"></span>
    </div>
    <button class="note__submit" type="submit">送信する</button>
  </div>
  <p class="note__status" aria-live="polite"></p>
</form>

CSS
.note {
  --ink: #262626;                    /* 文字・ふだんの輪・フォーカスの枠・ボタン */
  --field: #f2f1ed;                  /* 欄の地 */
  --line: rgba(38, 38, 38, 0.12);    /* 休んでいる枠と、輪の下地 */
  --mute: #898989;                   /* プレースホルダー */
  --mute-small: #6b6b6b;             /* ラベルと「60字まで」 */
  --warn: #c07a3a;                   /* 残りが少ないときの輪 */
  --warn-text: #9a5a22;              /* その数字(小さい文字でも読める濃さ) */
  --error: #c4553d;                  /* 超えたときの輪 */
  --error-text: #a8432d;             /* その数字と「60字まで」 */
  --disabled: #c8c7c3;               /* 押せないボタン */
  display: grid;
  gap: 8px;
  max-width: 544px;
  color: var(--ink);
}

.note__label { font-size: 13px; font-weight: 600; letter-spacing: 0.06em; color: var(--mute-small); }

.note__field {
  box-sizing: border-box;
  width: 100%;
  padding: 16px;
  border: 1px solid var(--line);
  border-radius: 4px;
  background: var(--field);
  color: inherit;
  font: inherit;
  font-size: 16px;
  line-height: 28px;
  letter-spacing: 0.04em;
  resize: vertical;
  outline: none;
  transition: border-color 0.2s, box-shadow 0.2s;
}

.note__field::placeholder { color: var(--mute); }
/* フォーカスの輪は、欄の外側に 4px 出す(outline の代わり) */
.note__field:focus { border-color: var(--ink); box-shadow: 0 0 0 4px var(--line); }

.note__footer { display: flex; align-items: center; gap: 16px; }
.note__hint { margin: 0 auto 0 0; font-size: 13px; letter-spacing: 0.04em; color: var(--mute-small); transition: color 0.2s; }
.note.is-over .note__hint { color: var(--error-text); }

.counter { --ring: var(--ink); --number: var(--warn-text); position: relative; flex: none; width: 48px; height: 48px; }

.counter.is-warn { --ring: var(--warn); }
.counter.is-over { --ring: var(--error); --number: var(--error-text); }

/* 12 時の位置から時計回りに満ちるよう、輪ごと -90° 回す */
.counter__ring { display: block; width: 100%; height: 100%; overflow: visible; transform: rotate(-90deg); }
.counter__track,
.counter__bar { fill: none; stroke-width: 3; }
.counter__track { stroke: var(--line); }
/* 満ちる長さ(stroke-dasharray)は JS が要素の style に書く */
.counter__bar { stroke: var(--ring); stroke-linecap: round; opacity: 0; transition: stroke 0.25s; }

.counter__number {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  font-size: 12px;
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  color: var(--number);
  opacity: 0; /* 出し入れは JS */
  transition: color 0.25s;
}

.note__submit {
  height: 48px;
  padding: 0 32px;
  border: 0;
  border-radius: 999px;
  background-color: var(--ink);
  color: #ffffff;
  font: inherit;
  font-size: 15px;
  font-weight: 600;
  letter-spacing: 0.04em;
  cursor: pointer;
  transition: background-color 0.2s;
}

.note__submit[aria-disabled="true"] { background-color: var(--disabled); cursor: not-allowed; }
.note__submit:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }

/* 読み上げだけに届ける領域(見た目からは消す) */
.note__status { position: absolute; width: 1px; height: 1px; margin: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }

@media (prefers-reduced-motion: reduce) {
  .note__field, .note__hint, .counter__bar, .counter__number, .note__submit { transition: none; }
}

JavaScript
import { gsap } from "gsap";

const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// 見た目の 1 字(書記素)で数える。length だと、絵文字などを 2 字と数える
const segmenter = new Intl.Segmenter("ja", { granularity: "grapheme" });
const count = (text) => [...segmenter.segment(text)].length;
const RADIUS = { rest: 12, warn: 19 }; // 輪の半径(viewBox の 48 に対して)

document.querySelectorAll(".note").forEach((form) => {
  const field = form.querySelector(".note__field");
  const counter = form.querySelector(".counter");
  const [track, bar] = counter.querySelectorAll("circle");
  const number = counter.querySelector(".counter__number");
  const submit = form.querySelector(".note__submit");
  const status = form.querySelector(".note__status");
  const limit = Number(form.dataset.limit);
  const warnAt = Number(form.dataset.warn);
  const speed = reduceMotion ? 0 : 1; // 動きを減らす設定では、補間せずに切り替える

  const ring = { fill: 0, r: RADIUS.rest };
  const draw = () => {
    bar.style.strokeDasharray = `${ring.fill} 100`; // pathLength="100" なので、% のまま長さになる
    bar.style.opacity = ring.fill > 0.2 ? 1 : 0; // 丸い線端は、長さ 0 でも点が残る
    track.setAttribute("r", ring.r);
    bar.setAttribute("r", ring.r);
  };

  // 読み上げる節目(1: 残り data-warn 字以下 / 2: その半分以下 / 3: ちょうど上限 / 4: 超えた)
  const milestoneOf = (left) => (left < 0 ? 4 : left === 0 ? 3 : left <= warnAt / 2 ? 2 : left <= warnAt ? 1 : 0);
  let milestone = 0;

  function update() {
    const used = count(field.value);
    const left = limit - used;
    const warn = left <= warnAt;
    const over = left < 0;
    // 打つたびに、今の値から次の値へ補間し直す(overwrite で前のトゥイーンの同じ値を止める)
    const vars = { overwrite: "auto", onUpdate: draw };
    gsap.to(ring, { fill: Math.min(used / limit, 1) * 100, duration: 0.35 * speed, ease: "power2.out", ...vars });
    gsap.to(ring, { r: warn ? RADIUS.warn : RADIUS.rest, duration: 0.4 * speed, ease: warn ? "back.out(2)" : "power2.out", ...vars });
    gsap.to(number, { opacity: warn ? 1 : 0, duration: 0.25 * speed, overwrite: "auto" });
    if (warn) number.textContent = String(left).replace("-", "−");
    // 色は CSS の transition で移す
    counter.classList.toggle("is-warn", warn && !over);
    counter.classList.toggle("is-over", over);
    form.classList.toggle("is-over", over);
    field.setAttribute("aria-invalid", String(over));
    // 押せないときも焦点を受けられるよう、disabled ではなく aria-disabled で止める
    submit.setAttribute("aria-disabled", String(used === 0 || over));

    // 読み上げは節目が変わったときだけ。打つたびに書くと、読み上げが打鍵に追いつかない
    const next = milestoneOf(left);
    if (next !== milestone && next > 0) {
      status.textContent = over ? `${-left}字多すぎます` : left === 0 ? `上限の${limit}字に達しました` : `残り${left}字です`;
    }
    milestone = next;
  }

  field.addEventListener("input", update);
  // 空のときと超えたときは送らず、欄へ戻す
  form.addEventListener("submit", (event) => {
    if (submit.getAttribute("aria-disabled") !== "true") return;
    event.preventDefault();
    field.focus();
  });
  // reset は input を起こさないので、値が戻ってから数え直す
  form.addEventListener("reset", () => requestAnimationFrame(update));
  update();
});

インタラクションデザインについて、
お気軽にご相談ください。

ここに並べているのは、Web サイトの中で動く UI のサンプルです。新しく作るサイトに組み込むことも、いま公開中のサイトに、ボタンやヒーロー、スクロールの演出だけを足すこともできます。設計から実装までの進め方はWeb サイト制作、端末ごとの見え方や表示速度の考え方はレスポンシブウェブサイトのページにまとめています。映像として作る動きをお探しの場合はモーショングラフィックスをご覧ください。