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 / maxCharacters | 160 | Positive integer grapheme limit; HTML attribute or React prop. |
--counter-accent | #136b55 | Normal usage bar and focus outline. |
--counter-warning | #865700 | Warning color at 90% usage. |
--counter-error | #b42332 | Over-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.