npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

true-form-length

v1.0.0

Published

Calculate form input character count accurately for HTTP submission and backend/DB storage

Downloads

91

Readme

true-form-length

npm version License: MIT

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("🎉") → 1

Problem 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-length

Usage

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

License

MIT