入力欄の下に「42 / 100文字」と表示する文字カウンターは、SNSの投稿欄やプロフィール、問い合わせフォームなどで広く使われます。v-modelとcomputedで簡単に作れますが、日本語のサイトでは、「文字数」の数え方でつまずきやすい部分があります。この記事では、Vue 3(<script setup>)で、日本語入力や絵文字にも対応した文字カウンターを作ります。
よくある実装と、その問題点
<script setup>
import { ref, computed } from 'vue'
const text = ref('')
const maxLength = 100
const remaining = computed(() => maxLength - text.value.length)
</script>
<template>
<label>自己紹介({{ remaining }}文字残り)</label>
<textarea v-model="text" :maxlength="maxLength"></textarea>
<p :class="{ warning: remaining <= 10 }">{{ text.length }} / {{ maxLength }} 文字</p>
</template>
- 数え方が、見た目の文字数と合わない:
text.lengthとmaxlengthは、どちらもUTF-16のコード単位で数えます(MDNで確認)。絵文字や一部の漢字で、数がずれます。 - 日本語の変換中に、カウンターが止まる:
v-modelは、IMEの変換中には更新されません。 - 貼り付けた長い文章が、黙って切り捨てられる:
maxlengthは入力を打ち切るだけで、超過の状態を見せられません。 - ラベルに残数が入っている:入力のたびにラベルが変わり、読み上げソフトが何度も読み上げます。
「文字数」には3つの数え方がある
JavaScriptで「文字数」を数える方法は、主に3つあります。同じ文字列でも、結果が違います(Nodeで実測)。
| 文字列 | length(UTF-16) | コードポイント | 書記素(見た目の文字数) |
|---|---|---|---|
| あいう | 3 | 3 | 3 |
| 𠮷野家 | 4 | 3 | 3 |
| 👨👩👧(家族の絵文字) | 8 | 5 | 1 |
| 🇯🇵(国旗) | 4 | 2 | 1 |
利用者が「1文字」と数える単位は、書記素です。Intl.Segmenterで数えます。書記素で数える場合は、maxlengthの単位(UTF-16)とずれるため、maxlengthは外して、自前で上限を管理する必要があります。
// utils/countChars.js
// 人が「1文字」と数える単位(書記素)で数える
const segmenter =
typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function'
? new Intl.Segmenter('ja', { granularity: 'grapheme' })
: null
export function countChars(value) {
if (segmenter) {
let count = 0
for (const _ of segmenter.segment(value)) count++
return count
}
// Intl.Segmenter が使えない環境では、コードポイント単位で数える
return [...value].length
}
// countChars('\u{20BB7}野家') → 3 (value.length は 4)
// countChars('\u{1F468}\u{1F469}\u{1F467}') → 1 (value.length は 8)
Intl.Segmenterが使えない古い環境では、[...value].length(コードポイント単位)で代替します。
完成コード
ポイントは次のとおりです。
- 日本語の変換中も更新するため、
v-modelではなく、:valueと@inputでバインドする。詳しくはv-modelの仕組みを完全理解をご覧ください。 maxlengthは付けず、超過を許して、超過した数を見せる(ソフトリミット)。- ラベルは固定し、カウンターは別の要素にして、
aria-describedbyで入力欄と結びつける。 - 残りわずかのときと超過のときだけ、
role="status"の領域に文言を入れて、読み上げさせる。色だけに頼らず、文言でも伝える。 - 超過しても送信ボタンは無効にせず、送信時にエラーを示して、入力欄へフォーカスを戻す。
<script setup>
import { ref, computed } from 'vue'
import { countChars } from './utils/countChars'
const MAX = 100 // 上限(文字)
const WARN_AT = 10 // 残りがこの数以下になったら、注意を出す
const text = ref('')
const submitted = ref(false)
const count = computed(() => countChars(text.value))
const remaining = computed(() => MAX - count.value)
const isOver = computed(() => remaining.value < 0)
const isNear = computed(() => remaining.value >= 0 && remaining.value <= WARN_AT)
// 読み上げと表示の両方に使う文言(色だけに頼らない)
const message = computed(() => {
if (isOver.value) return `${-remaining.value}文字オーバーしています。削ってください。`
if (isNear.value) return `あと${remaining.value}文字です。`
return ''
})
function onSubmit() {
submitted.value = true
if (isOver.value) {
document.getElementById('bio')?.focus()
return
}
// 送信する(サーバー側でも、必ず文字数を検証する)
console.log('送信:', text.value)
}
</script>
<template>
<form @submit.prevent="onSubmit">
<label for="bio">自己紹介</label>
<!-- v-model ではなく :value + @input:日本語の変換中も、カウンターを更新する -->
<!-- maxlength は付けない:貼り付けが黙って切り捨てられるのを避け、超過を見せる -->
<textarea
id="bio"
rows="4"
:value="text"
:aria-invalid="isOver"
aria-describedby="bio-count bio-message"
@input="text = $event.target.value"
></textarea>
<p id="bio-count" class="counter" :class="{ near: isNear, over: isOver }">
{{ count }} / {{ MAX }} 文字
</p>
<!-- 残りわずかのときと超過のときだけ、文言が入る(常に DOM に存在する) -->
<p id="bio-message" role="status" class="message" :class="{ over: isOver }">
{{ message }}
</p>
<!-- 超過しても、ボタンは無効にしない。送信時にエラーを示す -->
<button type="submit">送信</button>
</form>
</template>
<style scoped>
.counter { text-align: right; font-size: 0.9rem; }
.counter.near { color: #b26a00; font-weight: bold; }
.counter.over, .message.over { color: #c62828; font-weight: bold; }
textarea[aria-invalid="true"] { border-color: #c62828; }
</style>
computedの基本はcomputedプロパティで動的なデータ処理を行う方法、フォームのバリデーション全般は入力フォームのバリデーション機能を実装する方法をご覧ください。
文字数とバイト数は別もの
データベースの列やAPIの制限が、バイト数で決まっている場合は、バイト数を数える必要があります。「日本語は2バイト」とよく言われますが、現在のWebで標準のUTF-8では、ひらがな・カタカナ・漢字は1文字3バイト、𠮷や絵文字は4バイトです。2バイトになるのは、Shift_JISなどの旧来の文字コードです。UTF-8のバイト数は、TextEncoderで数えられます。
// バイト数を数える(DB の列の制限などに合わせたいとき)
const bytes = new TextEncoder().encode(text).length
// UTF-8 の場合
// 'abc' → 3 バイト
// 'あいう' → 9 バイト(日本語は 1 文字 3 バイト)
// '\u{20BB7}' → 4 バイト
// '\u{1F600}' → 4 バイト
サーバー側の数え方と揃える
ブラウザ上の制限は、入力を助けるためのものです。
maxlengthも、MDNによると、プログラムから値を設定した場合には効きません。サーバー側でも、必ず検証してください。注意したいのは、サーバー側の「文字数」の数え方が、言語や関数で違うことです。たとえばPHPのmb_strlen()はコードポイント単位、grapheme_strlen()(intl拡張)は書記素単位です。クライアントとサーバーで数え方が違うと、フロントで通ったのに、サーバーで拒否されることがあります。「文字数」の定義を、仕様として決めて、両方で揃えてください。また、textareaの改行は、フォームの送信時にCRLFへ変換される場合があり、サーバー側では、改行が2文字として数えられることがあります。
よくある質問(FAQ)
text.lengthは、UTF-16のコード単位で数えるためです。絵文字は2以上になります。Intl.Segmenterで書記素を数えると、見た目の文字数になります。v-modelが更新されないためです。:valueと@inputで自前でバインドしてください。maxlengthは外して、自前で管理してください。new TextEncoder().encode(text).lengthで数えられます。まとめ
文字カウンターは、「文字数」の定義を決めることから始まります。見た目の文字数に合わせるなら、Intl.Segmenterで書記素を数え、maxlengthに頼らず、超過を見せる設計にします。日本語の変換中も更新し、読み上げに対応し、サーバー側の数え方と揃えて、必ず検証し直してください。

