true-form-length
v1.0.0
Published
Calculate form input character count accurately for HTTP submission and backend/DB storage
Downloads
91
Maintainers
Readme
true-form-length
A lightweight JavaScript/TypeScript library that accurately calculates how form input values (<textarea>, <input>) will be counted after HTTP submission and in backend/database storage.
Why?
Problem 1: Surrogate Pairs
'🎉'.length // → 2 (JavaScript)
// MySQL CHAR_LENGTH("🎉") → 1Problem 2: Newline Normalization
textarea.value // "a\nb" → 3 chars (JavaScript)
// After HTTP submission → "a\r\nb" → 4 chars (Backend)Problem 3: Lone Surrogates
Corrupted data from copy-paste. MySQL treats these as invalid UTF-8.
Installation
npm install true-form-lengthUsage
Basic
import { countChars, isValidLength } from 'true-form-length'
const text = 'Hello\nWorld🎉'
const result = countChars(text)
// {
// length: 13, // "Hello\r\nWorld🎉" = 13 code points
// byteLength: 16, // UTF-8 bytes
// hasLoneSurrogate: false,
// newlineCount: 1
// }
// VARCHAR(255) validation
if (isValidLength(text, 255)) {
// OK
}Common Presets
Built-in constants for popular character limits:
import {
X_LIMIT, // 280
SMS_LIMIT, // 160
MYSQL_VARCHAR, // 255
MYSQL_TEXT, // 65535
INSTAGRAM_BIO, // 150
FACEBOOK_POST, // 63206
LINKEDIN_POST, // 3000
YOUTUBE_TITLE, // 100
YOUTUBE_DESCRIPTION, // 5000
TIKTOK_CAPTION, // 2200
X_URL_LENGTH, // 23
} from 'true-form-length'
// Use with validators
const isValidTweet = maxLength(X_LIMIT)
// X-style URL counting (all URLs count as 23 chars)
const result = countChars(text, { urlLength: X_URL_LENGTH })URL Encoding Validation
Validate strings that will be used in URLs (query parameters, path segments):
import {
countChars,
isValidUrlEncodedLength,
truncateUrlEncoded,
urlEncode,
} from 'true-form-length'
// Check URL-encoded length
const result = countChars('こんにちは')
console.log(result.urlEncodedLength) // 45 (each hiragana is 9 chars when encoded)
// Validate for URL query parameters (URLs typically limited to 2048 chars)
isValidUrlEncodedLength('Hello World', 2000) // true (13 chars: Hello%20World)
isValidUrlEncodedLength('こんにちは', 40) // false (45 chars when encoded)
// Truncate to fit URL limits
truncateUrlEncoded('Hello World!', 10) // "Hello W"
truncateUrlEncoded('あいう', 18) // "あい" (each char = 9 encoded)
// Get the encoded string
urlEncode('Hello World') // "Hello%20World"
urlEncode('検索') // "%E6%A4%9C%E7%B4%A2"React Hook
import { useState } from 'react'
import { useFormLength } from 'true-form-length/react'
function TextArea({ maxLength }: { maxLength: number }) {
const [value, setValue] = useState('')
const { length, remaining, isOver } = useFormLength(value, maxLength)
return (
<div>
<textarea value={value} onChange={(e) => setValue(e.target.value)} />
<span style={{ color: isOver ? 'red' : 'inherit' }}>
{length} / {maxLength}
</span>
</div>
)
}React Components (Radix UI Style)
import * as Textarea from 'true-form-length/react/textarea'
function CommentForm() {
return (
<Textarea.Root maxLength={255}>
<Textarea.Input placeholder="Enter your comment..." />
<Textarea.Counter />
<Textarea.Error>Character limit exceeded</Textarea.Error>
</Textarea.Root>
)
}
// With custom render function
;<Textarea.Counter>
{({ length, maxLength, remaining, isOver }) => (
<span className={isOver ? 'error' : ''}>
{remaining} characters remaining
</span>
)}
</Textarea.Counter>import * as Input from 'true-form-length/react/input'
function NameField() {
return (
<Input.Root maxLength={50}>
<Input.Field placeholder="Your name" />
<Input.Remaining />
</Input.Root>
)
}Progress Bar & Stats
<Textarea.Root maxLength={280} warningThreshold={0.9}>
<Textarea.Input />
<Textarea.Progress /> {/* Built-in progress bar */}
<Textarea.Counter />
<Textarea.Stats /> {/* Shows: "5 words · 2 sentences · 1 min read" */}
</Textarea.Root>Warning Threshold
Show a warning state before exceeding the limit:
// Percentage (0-1): warn at 90% capacity
<Textarea.Root maxLength={280} warningThreshold={0.9}>
// Absolute: warn when 20 chars remaining
<Textarea.Root maxLength={280} warningThreshold={20}>CSS targeting:
textarea[data-state='warning'] {
border-color: orange;
}Auto-Truncate
Automatically truncate input to stay within the limit:
<Textarea.Root maxLength={280} autoTruncate>
<Textarea.Input /> {/* Cannot exceed 280 chars */}
</Textarea.Root>Using with Tailwind CSS
All components expose data attributes for styling:
<Textarea.Root maxLength={255}>
<Textarea.Input className="border data-[state=over]:border-red-500" />
<Textarea.Counter className="text-gray-500 data-[over]:text-red-500" />
</Textarea.Root>asChild Pattern
Use asChild to render your own component while inheriting props:
import * as Textarea from 'true-form-length/react/textarea'
import { MyCustomTextarea } from './my-components'
;<Textarea.Root maxLength={255}>
<Textarea.Input asChild>
<MyCustomTextarea className="custom-styles" />
</Textarea.Input>
</Textarea.Root>Wrapper Component
Use Wrapper to access data attributes on a container:
<Textarea.Root maxLength={255}>
<Textarea.Wrapper className="group data-[state=over]:bg-red-50">
<Textarea.Input />
<Textarea.Counter />
</Textarea.Wrapper>
</Textarea.Root>Vue Composable
<script setup lang="ts">
import { ref } from 'vue'
import { useFormLength } from 'true-form-length/vue'
const text = ref('')
const { length, remaining, isOver } = useFormLength(text, 255)
</script>
<template>
<div>
<textarea v-model="text" />
<span :class="{ error: isOver }">{{ length }} / 255</span>
</div>
</template>Vue Components (Radix UI Style)
<script setup lang="ts">
import * as Textarea from 'true-form-length/vue/textarea'
</script>
<template>
<Textarea.Root :max-length="255">
<Textarea.Input placeholder="Enter your comment..." />
<Textarea.Counter />
<Textarea.Error>Character limit exceeded</Textarea.Error>
</Textarea.Root>
</template><script setup lang="ts">
import * as Input from 'true-form-length/vue/input'
</script>
<template>
<Input.Root :max-length="50">
<Input.Field placeholder="Your name" />
<Input.Remaining />
</Input.Root>
</template>Lit Web Components
Web Components that work anywhere - vanilla HTML, React, Vue, Angular, etc.
<script type="module">
import 'true-form-length/lit/textarea'
</script>
<fl-textarea max-length="255" placeholder="Enter your comment...">
</fl-textarea><script type="module">
import 'true-form-length/lit/input'
</script>
<fl-input max-length="50" placeholder="Your name"></fl-input>Customization
<!-- Hide default counter/error, use custom slots -->
<fl-textarea max-length="255" hide-counter hide-error>
<span slot="counter">Custom counter</span>
<span slot="error">Custom error</span>
</fl-textarea>
<!-- Custom error message -->
<fl-input max-length="50" error-message="Too long!"></fl-input>Styling with CSS Custom Properties
fl-textarea {
--fl-textarea-border: 1px solid #e5e7eb;
--fl-textarea-border-over: 2px solid #ef4444;
--fl-counter-color: #6b7280;
--fl-counter-color-over: #ef4444;
--fl-error-color: #ef4444;
}Styling with CSS Parts
fl-textarea::part(textarea) {
font-size: 16px;
padding: 12px;
}
fl-textarea::part(counter) {
font-size: 12px;
}Events
const textarea = document.querySelector('fl-textarea')
textarea.addEventListener('fl-input', (e) => {
console.log(e.detail.value, e.detail.stats)
})
textarea.addEventListener('fl-over', () => {
console.log('Character limit exceeded!')
})
textarea.addEventListener('fl-within', () => {
console.log('Back within limit')
})Properties
| Property | Attribute | Type | Default |
| -------------- | --------------- | ------- | -------------------------- |
| maxLength | max-length | number | 255 |
| value | value | string | '' |
| placeholder | placeholder | string | '' |
| disabled | disabled | boolean | false |
| readonly | readonly | boolean | false |
| newline | newline | string | 'crlf' |
| hideCounter | hide-counter | boolean | false |
| hideError | hide-error | boolean | false |
| errorMessage | error-message | string | 'Character limit exceeded' |
| rows | rows | number | 3 (textarea only) |
| type | type | string | 'text' (input only) |
Options
// LF normalization (if backend converts \r\n → \n)
const result = countChars(text, { newline: 'lf' })
// No normalization
const result = countChars(text, { newline: 'none' })API
Core Functions
countChars(str, options?)
Returns character count information.
interface CountResult {
length: number // Code point count (MySQL CHAR_LENGTH compatible)
byteLength: number // UTF-8 byte length (MySQL LENGTH compatible)
urlEncodedLength: number // Length after percent-encoding
hasLoneSurrogate: boolean // Contains invalid surrogate?
newlineCount: number // Number of newlines
wordCount: number // Number of words (split by whitespace)
sentenceCount: number // Number of sentences (split by .!?)
readingTime: number // Estimated reading time in minutes (200 wpm)
}isValidLength(str, maxLength, options?)
Returns true if within the character limit. Useful for VARCHAR(N) validation.
isValidByteLength(str, maxBytes, options?)
Returns true if within the byte limit. Useful for index size restrictions.
isValidUrlEncodedLength(str, maxLength, options?)
Returns true if within the URL-encoded length limit. Useful for query parameter validation (URLs typically limited to 2048 characters).
// Check if a search query would fit in URL
isValidUrlEncodedLength('こんにちは', 50) // → false (45 chars when encoded)Utility Functions
serialize(str, options?)
Returns a normalized string with CRLF conversion and lone surrogates removed.
serialize('Hello\nWorld') // → "Hello\r\nWorld"truncate(str, maxLength, options?)
Truncates a string to the specified code point length.
truncate('Hello🎉World', 6) // → "Hello🎉"truncateBytes(str, maxBytes, options?)
Truncates a string to fit within the specified UTF-8 byte length.
truncateBytes('Hello🎉', 8) // → "Hello🎉" (9 bytes) → "Hello" (5 bytes)urlEncode(str, options?)
URL-encodes a string after serialization. Useful for building query parameters with accurate length calculation.
urlEncode('Hello World') // → "Hello%20World"
urlEncode('こんにちは') // → "%E3%81%93%E3%82%93%E3%81%AB%E3%81%A1%E3%81%AF"truncateUrlEncoded(str, max, options?)
Truncates a string to fit within the specified URL-encoded length. Handles multi-byte characters correctly (won't break in middle of encoded sequence).
truncateUrlEncoded('Hello World', 10) // → "Hello W" (space = %20 = 3 chars)
truncateUrlEncoded('あい', 9) // → "あ" (each hiragana = 9 chars encoded)remaining(str, maxLength, options?)
Returns the number of remaining characters until the limit.
remaining('Hello', 10) // → 5
remaining('Hello World', 5) // → -6 (negative when over)minLength(min, options?)
Returns a validator function for minimum character length.
lengthInRange(min, max, options?)
Returns a validator function for character length within a range.
Options
interface CountOptions {
newline?: 'crlf' | 'lf' | 'none' // Default: 'crlf'
urlLength?: number // Fixed length for URLs (like X's t.co)
}| Option | Description |
| ----------- | ---------------------------------------------------- |
| newline | How to normalize newlines |
| urlLength | Count all URLs as this fixed length (e.g., 23 for X) |
Newline values:
| Value | Description |
| -------- | ------------------------------------------------------------- |
| 'crlf' | Convert \n → \r\n (default, matches HTTP form submission) |
| 'lf' | Convert \r\n → \n |
| 'none' | No normalization |
Zod / Yup Helpers
Helper functions for schema validation libraries.
import {
maxLength,
maxByteLength,
maxUrlEncodedLength,
noLoneSurrogates,
} from 'true-form-length'maxLength(max, options?)
Returns a validator function for character length.
maxByteLength(max, options?)
Returns a validator function for byte length.
maxUrlEncodedLength(max, options?)
Returns a validator function for URL-encoded length. Useful for validating query parameters.
// Validate that a search term won't make the URL too long
const validator = maxUrlEncodedLength(2000)
validator('Hello') // true (5 chars)
validator('こんにちは') // true (45 chars when encoded)noLoneSurrogates()
Returns a validator function that rejects lone surrogates.
Zod Example
import { z } from 'zod'
import {
maxLength,
maxByteLength,
maxUrlEncodedLength,
noLoneSurrogates,
} from 'true-form-length'
const schema = z.object({
name: z
.string()
.refine(maxLength(255), { message: 'Must be 255 characters or less' })
.refine(noLoneSurrogates(), { message: 'Invalid characters' }),
bio: z.string().refine(maxByteLength(767), { message: 'Too long for index' }),
// Ensure search terms won't make URLs too long
searchQuery: z.string().refine(maxUrlEncodedLength(2000), {
message: 'Search term too long for URL',
}),
})Yup Example
import * as yup from 'yup'
import { maxLength, maxByteLength, noLoneSurrogates } from 'true-form-length'
const schema = yup.object({
name: yup
.string()
.test('maxLength', 'Must be 255 characters or less', maxLength(255))
.test('noLoneSurrogates', 'Invalid characters', noLoneSurrogates()),
bio: yup
.string()
.test('maxByteLength', 'Too long for index', maxByteLength(767)),
})Data Attributes
All components expose data attributes for CSS styling (Tailwind, vanilla CSS, etc.):
| Attribute | Values | Description |
| ------------------------- | ------------------------------------- | ------------------------------------------------- |
| data-state | "within" | "warning" | "over" | Character count state (requires warningThreshold) |
| data-valid | "" (present/absent) | Present when within limit |
| data-invalid | "" (present/absent) | Present when over limit |
| data-over | "" (present/absent) | Present when over limit (alias) |
| data-warning | "" (present/absent) | Present when in warning state |
| data-has-lone-surrogate | "" (present/absent) | Present when text contains lone surrogates |
| data-remaining | number | Remaining character count (Wrapper only) |
| data-length | number | Current character count (Wrapper only) |
| data-max-length | number | Maximum allowed characters (Wrapper only) |
| data-progress | number (0-100) | Progress percentage (Progress component) |
| data-word-count | number | Word count (Stats component) |
| data-sentence-count | number | Sentence count (Stats component) |
| data-reading-time | number | Reading time in minutes (Stats component) |
CSS Examples
/* Vanilla CSS */
textarea[data-state='over'] {
border-color: red;
}
span[data-over] {
color: red;
}<!-- Tailwind CSS -->
<Textarea.Input class="border data-[state=over]:border-red-500" />
<Textarea.Counter class="data-[over]:text-red-500" />Exports
| Path | Description |
| --------------------------------- | ----------------------------------- |
| true-form-length | Core functions |
| true-form-length/react | React hook (useFormLength) |
| true-form-length/react/textarea | React Textarea components |
| true-form-length/react/input | React Input components |
| true-form-length/vue | Vue composable (useFormLength) |
| true-form-length/vue/textarea | Vue Textarea components |
| true-form-length/vue/input | Vue Input components |
| true-form-length/lit/textarea | Lit Web Component (<fl-textarea>) |
| true-form-length/lit/input | Lit Web Component (<fl-input>) |
Features
- Zero dependencies (core)
- ESM and CommonJS support
- TypeScript types included
- React, Vue, and Lit support (optional peer dependencies)
- Radix UI-style compound components
- Web Components for framework-agnostic usage
Browser Support
- Modern browsers (ES2018+)
- Node.js >= 16
