F-006 Form simple

How to build a textarea character counter 文字数カウンター付きテキストエリアの作り方

Keep notes within a character budget without cutting off pasted text or Japanese input. 貼り付けた文章や日本語入力を切り捨てず、文字数の目安と超過状態を確認できます。

ライブデモ Live Demo

絵文字も1文字。上限を超えても入力は消えません。Emoji count as one character. Your text stays intact if you go over.

概要・用途・特徴Overview, Usage & Features

何ができるかWhat it does

A textarea that counts grapheme clusters instead of UTF-16 code units. A family emoji or a letter with a combining accent counts as one visible character.

UTF-16のコード単位ではなく書記素クラスタを数えるテキストエリアです。家族の絵文字や結合アクセント付きの文字も、見た目の1文字として扱います。

どこで使うかWhere to use

Use it for short biographies, feedback, reviews, and other fields with a user-facing character budget.

短い自己紹介、フィードバック、レビューなど、入力できる文字数の目安を示すフィールドに適しています。

特徴Key features

Live used and remaining counts, a usage bar, a warning at 90%, an over-limit error, native form validity, and a clear button that returns focus to the textarea.

入力済み・残り文字数、使用量バー、90%到達時の警告、上限超過エラー、標準フォーム検証、入力欄へフォーカスを戻すクリアボタンを備えます。

調整可能パラメータ Adjustable Parameters

Parameter Default Description
data-max-characters / maxCharacters160Positive integer grapheme limit; HTML attribute or React prop.
--counter-accent#136b55Normal usage bar and focus outline.
--counter-warning#865700Warning color at 90% usage.
--counter-error#b42332Over-limit feedback color.

実装コード Implementation Code

<div class="character-counter demo" data-character-counter data-max-characters="160" data-state="normal">
  <div class="character-counter__heading">
    <label class="character-counter__label" for="counter-message"><span>A little note</span></label>
    <span class="character-counter__index" aria-hidden="true">Aa / 01</span>
  </div>
  <textarea class="character-counter__input" id="counter-message" name="message" rows="4" aria-describedby="counter-hint counter-remaining counter-status" aria-invalid="false"></textarea>
  <div class="character-counter__footer">
    <span class="character-counter__count" aria-hidden="true"><span data-count>0</span> / <span data-limit>160</span></span>
    <span class="character-counter__remaining" id="counter-remaining">
      
      <span data-remaining-en>160 characters remaining</span>
    </span>
  </div>
  <div class="character-counter__track" aria-hidden="true"><span class="character-counter__fill" data-fill></span></div>
  <p class="character-counter__hint" id="counter-hint"><span>Emoji count as one character. Your text stays intact if you go over.</span></p>
  <p class="character-counter__status" id="counter-status" role="status" aria-atomic="true">
    <span data-status-en></span>
  </p>
  <button class="character-counter__clear" type="button" data-clear><span>Clear note</span></button>
</div>

<script>
const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
document.querySelectorAll('[data-character-counter]').forEach((root) => {
  const input = root.querySelector('textarea');
  const limit = Number(root.dataset.maxCharacters);
  if (!Number.isInteger(limit) || limit < 1) throw new RangeError('Use a positive integer character limit.');

  function render() {
    let count = 0;
    for (const segment of segmenter.segment(input.value)) count += 1;
    const remaining = limit - count;
    const state = remaining < 0 ? 'error' : count >= Math.ceil(limit * 0.9) ? 'warning' : 'normal';
    root.dataset.state = state;
    root.querySelector('[data-count]').textContent = count;
    root.querySelector('[data-limit]').textContent = limit;
    root.querySelector('[data-remaining-en]').textContent = remaining < 0 ? Math.abs(remaining) + ' characters over limit' : remaining + ' characters remaining';
    root.querySelector('[data-fill]').style.width = Math.min(100, count / limit * 100) + '%';
    input.setAttribute('aria-invalid', String(remaining < 0));
    input.setCustomValidity(remaining < 0 ? 'Character limit exceeded.' : '');

    // Only change the live region at a boundary, not on every keystroke.
    const en = state === 'error' ? 'Over the limit. Please shorten your note.' : state === 'warning' ? 'You are approaching the character limit.' : '';
    const statusEn = root.querySelector('[data-status-en]');
    if (statusEn.textContent !== en) statusEn.textContent = en;
  }

  input.addEventListener('input', render);
  input.addEventListener('compositionend', render);
  root.querySelector('[data-clear]').addEventListener('click', () => {
    input.value = '';
    input.dispatchEvent(new Event('input', { bubbles: true }));
    input.focus();
  });
  input.form?.addEventListener('reset', (event) => {
    queueMicrotask(() => { if (!event.defaultPrevented) render(); });
  });
  render();
});
</script>
.character-counter {
  --counter-accent: #136b55;
  --counter-warning: #865700;
  --counter-error: #b42332;
  box-sizing: border-box;
  width: 100%;
  max-width: 560px;
  margin: 0 auto;
  padding: clamp(16px, 4vw, 28px);
  border: 1px solid #dedbd0;
  border-top: 4px solid var(--counter-accent);
  border-radius: 4px 4px 16px 16px;
  background: #fcfbf7;
  color: #243d35;
  font-family: 'Trebuchet MS', 'Yu Gothic', sans-serif;
}

.character-counter * { box-sizing: border-box; }
.character-counter__heading { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
.character-counter__label { font-size: 20px; font-weight: 700; }
.character-counter__index { color: #5d7066; font: 12px/1.5 monospace; letter-spacing: .12em; }
.character-counter__input { display: block; width: 100%; min-height: 132px; padding: 14px; resize: vertical; border: 1px solid #a7b7ac; border-radius: 8px; background: #fff; color: #243d35; font-family: inherit; font-size: 16px; line-height: 1.7; }
.character-counter__input:focus-visible, .character-counter__clear:focus-visible { outline: 3px solid var(--counter-accent); outline-offset: 3px; }
.character-counter__footer { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 8px 16px; margin-top: 12px; }
.character-counter__count { font: 14px/1.5 monospace; font-variant-numeric: tabular-nums; }
.character-counter__remaining { font-size: 13px; }
.character-counter__track { height: 4px; margin: 12px 0; overflow: hidden; border-radius: 2px; background: #e1e7df; }
.character-counter__fill { display: block; height: 100%; width: 0; background: var(--counter-accent); }
.character-counter__hint, .character-counter__status { margin: 8px 0; font-size: 13px; line-height: 1.6; }
.character-counter__hint { color: #52665b; }
.character-counter__status { min-height: 1.6em; font-weight: 700; }
.character-counter__clear { min-height: 44px; padding: 8px 14px; border: 1px solid #a7b7ac; border-radius: 6px; background: #fff; color: #243d35; font: inherit; font-size: 14px; cursor: pointer; }
.character-counter__clear:hover { background: #edf3ec; }
.character-counter[data-state="warning"] .character-counter__status { color: var(--counter-warning); }
.character-counter[data-state="warning"] .character-counter__fill { background: var(--counter-warning); }
.character-counter[data-state="error"] .character-counter__input { border-color: var(--counter-error); }
.character-counter[data-state="error"] .character-counter__status, .character-counter[data-state="error"] .character-counter__remaining { color: var(--counter-error); }
.character-counter[data-state="error"] .character-counter__fill { background: var(--counter-error); }
import { useEffect, useId, useRef, useState } from 'react';
import './F-006.css';

const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });

export default function CharacterCounter({ maxCharacters = 160, locale = 'en', name = 'message' }) {
  const id = useId();
  const inputRef = useRef(null);
  const [value, setValue] = useState('');
  if (!Number.isInteger(maxCharacters) || maxCharacters < 1) {
    throw new RangeError('Use a positive integer character limit.');
  }
  let count = 0;
  for (const segment of segmenter.segment(value)) count += 1;
  const remaining = maxCharacters - count;
  const state = remaining < 0 ? 'error' : count >= Math.ceil(maxCharacters * 0.9) ? 'warning' : 'normal';
  const ja = locale === 'ja';
  const status = state === 'error'
    ? (ja ? '上限を超えています。文章を短くしてください。' : 'Over the limit. Please shorten your note.')
    : state === 'warning' ? (ja ? '文字数の上限に近づいています。' : 'You are approaching the character limit.') : '';
  const error = remaining < 0 ? '文字数の上限を超えています。 Character limit exceeded.' : '';

  useEffect(() => { inputRef.current.setCustomValidity(error); }, [error]);
  useEffect(() => {
    const form = inputRef.current.form;
    const reset = (event) => {
      queueMicrotask(() => { if (!event.defaultPrevented) setValue(''); });
    };
    form?.addEventListener('reset', reset);
    return () => form?.removeEventListener('reset', reset);
  }, []);

  return (
    <div className="character-counter" data-state={state}>
      <div className="character-counter__heading">
        <label className="character-counter__label" htmlFor={id}>{ja ? 'ひとことメモ' : 'A little note'}</label>
        <span className="character-counter__index" aria-hidden="true">Aa / 01</span>
      </div>
      <textarea
        ref={inputRef}
        className="character-counter__input"
        id={id}
        name={name}
        rows={4}
        value={value}
        aria-describedby={id + '-hint ' + id + '-remaining ' + id + '-status'}
        aria-invalid={remaining < 0}
        onChange={(event) => setValue(event.target.value)}
      />
      <div className="character-counter__footer">
        <span className="character-counter__count" aria-hidden="true">{count} / {maxCharacters}</span>
        <span className="character-counter__remaining" id={id + '-remaining'}>
          {remaining < 0
            ? (ja ? Math.abs(remaining) + ' 文字超過' : Math.abs(remaining) + ' characters over limit')
            : (ja ? '残り ' + remaining + ' 文字' : remaining + ' characters remaining')}
        </span>
      </div>
      <div className="character-counter__track" aria-hidden="true">
        <span className="character-counter__fill" style={{ width: Math.min(100, count / maxCharacters * 100) + '%' }} />
      </div>
      <p className="character-counter__hint" id={id + '-hint'}>
        {ja ? '絵文字も1文字。上限を超えても入力は消えません。' : 'Emoji count as one character. Your text stays intact if you go over.'}
      </p>
      <p className="character-counter__status" id={id + '-status'} role="status" aria-atomic="true">{status}</p>
      <button className="character-counter__clear" type="button" onClick={() => {
        setValue('');
        inputRef.current.focus();
      }}>{ja ? 'クリア' : 'Clear note'}</button>
    </div>
  );
}
.character-counter {
  --counter-accent: #136b55;
  --counter-warning: #865700;
  --counter-error: #b42332;
  box-sizing: border-box;
  width: 100%;
  max-width: 560px;
  margin: 0 auto;
  padding: clamp(16px, 4vw, 28px);
  border: 1px solid #dedbd0;
  border-top: 4px solid var(--counter-accent);
  border-radius: 4px 4px 16px 16px;
  background: #fcfbf7;
  color: #243d35;
  font-family: 'Trebuchet MS', 'Yu Gothic', sans-serif;
}

.character-counter * { box-sizing: border-box; }
.character-counter__heading { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
.character-counter__label { font-size: 20px; font-weight: 700; }
.character-counter__index { color: #5d7066; font: 12px/1.5 monospace; letter-spacing: .12em; }
.character-counter__input { display: block; width: 100%; min-height: 132px; padding: 14px; resize: vertical; border: 1px solid #a7b7ac; border-radius: 8px; background: #fff; color: #243d35; font-family: inherit; font-size: 16px; line-height: 1.7; }
.character-counter__input:focus-visible, .character-counter__clear:focus-visible { outline: 3px solid var(--counter-accent); outline-offset: 3px; }
.character-counter__footer { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 8px 16px; margin-top: 12px; }
.character-counter__count { font: 14px/1.5 monospace; font-variant-numeric: tabular-nums; }
.character-counter__remaining { font-size: 13px; }
.character-counter__track { height: 4px; margin: 12px 0; overflow: hidden; border-radius: 2px; background: #e1e7df; }
.character-counter__fill { display: block; height: 100%; width: 0; background: var(--counter-accent); }
.character-counter__hint, .character-counter__status { margin: 8px 0; font-size: 13px; line-height: 1.6; }
.character-counter__hint { color: #52665b; }
.character-counter__status { min-height: 1.6em; font-weight: 700; }
.character-counter__clear { min-height: 44px; padding: 8px 14px; border: 1px solid #a7b7ac; border-radius: 6px; background: #fff; color: #243d35; font: inherit; font-size: 14px; cursor: pointer; }
.character-counter__clear:hover { background: #edf3ec; }
.character-counter[data-state="warning"] .character-counter__status { color: var(--counter-warning); }
.character-counter[data-state="warning"] .character-counter__fill { background: var(--counter-warning); }
.character-counter[data-state="error"] .character-counter__input { border-color: var(--counter-error); }
.character-counter[data-state="error"] .character-counter__status, .character-counter[data-state="error"] .character-counter__remaining { color: var(--counter-error); }
.character-counter[data-state="error"] .character-counter__fill { background: var(--counter-error); }

仕組みとカスタマイズHow It Works & Customization

仕組みHow it works

Intl.Segmenter with granularity: 'grapheme' counts the value on input. The component never rewrites text while typing or composing. Over-limit text sets aria-invalid and setCustomValidity; returning within the limit clears both. Only threshold messages are in the live region.

Intl.Segmenterのgranularity: 'grapheme'で入力値を数えます。入力中・日本語変換中に文章を書き換えません。上限超過時にaria-invalidとsetCustomValidityを設定し、文字数が範囲内に戻ると解除します。ライブリージョンはしきい値のメッセージだけを通知します。

カスタマイズ方法Customization

Set data-max-characters in HTML or maxCharacters in React to a positive integer. React accepts locale='ja' and a field name. Change the three counter color variables to match your design. Give each copied HTML instance unique label and description IDs.

HTMLのdata-max-characters、またはReactのmaxCharactersに正の整数を指定します。Reactはlocale='ja'とnameも指定できます。3つの色変数で配色を変更できます。HTML版を複数置く場合はlabelと説明文のIDをそれぞれ一意にしてください。

注意点Caveats

Requires Intl.Segmenter (Baseline 2024) and React 18+ for the React sample. Include a Segmenter polyfill for older browsers. Do not add maxlength: it counts UTF-16 units. Client validity is a UX aid; use the same counting rule on your server. Spaces and line breaks count. Programmatic value changes need an input event.

Intl.Segmenter(Baseline 2024)対応ブラウザ、React版はReact 18以降が必要です。古いブラウザにはSegmenterのポリフィルを追加してください。maxlengthはUTF-16単位で数えるため併用しません。サーバー側も同じ数え方で検証してください。空白と改行も数えます。JavaScriptで値を変更した場合はinputイベントを発火してください。

よくある質問 Frequently Asked Questions

Why not use value.length or maxlength? value.lengthやmaxlengthを使わないのはなぜですか?

They count UTF-16 code units, so one emoji can consume multiple units. Intl.Segmenter counts grapheme clusters, which better match visible characters.

UTF-16のコード単位で数えるため、絵文字が複数文字分になることがあります。Intl.Segmenterなら見た目の文字に対応する書記素クラスタを数えられます。

What happens when the limit is exceeded? 上限を超えるとどうなりますか?

The full text is preserved. The error and aria-invalid state appear, and standard form validation blocks submission until the text is within the limit. This demo sends no data.

文章をすべて保持したままエラーとaria-invalidを表示します。標準フォーム検証を使う送信は、範囲内になるまで制限されます。このデモはデータを送信しません。

Does it interrupt Japanese input? 日本語入力の変換を妨げませんか?

No text is truncated or rewritten during composition. Counting follows the current textarea value and is refreshed at compositionend.

変換中の文字を切り捨てたり書き換えたりしません。入力欄の現在値を数え、変換確定時にも表示を更新します。

AIへの指示テンプレート AI Prompt Template

以下をAIに貼り付けると実装を依頼できます。 Paste the following into your AI assistant to request implementation.