Building a Text Case Converter in React — camelCase, PascalCase, snake_case, kebab-case, and More
How to implement 8 text case transformations with real-time conversion, copy-to-clipboard, and correct tokenization for mixed-input strings

A case converter sounds trivial until you think about the input: users paste myVariableName, MY_CONSTANT, some-slug-text, or Title Case Words and expect correct output regardless. Here's how the Case Converter handles it.
The Core Problem: Tokenization
Every case transformation comes down to one operation: split the input into words, then rejoin in the target format. The tricky part is splitting correctly from any input format.
function tokenize(input: string): string[] {
return input
// Insert a space before transitions from lowercase/digit to uppercase (camelCase → camel Case)
.replace(/([a-z\d])([A-Z])/g, '\(1 \)2')
// Insert a space before transitions from multiple uppercase to uppercase+lowercase (HTMLParser → HTML Parser)
.replace(/([A-Z]+)([A-Z][a-z])/g, '\(1 \)2')
// Replace hyphens, underscores, and dots with spaces
.replace(/[-_.\s]+/g, ' ')
// Trim and split on whitespace
.trim()
.split(/\s+/)
.filter(Boolean)
.map(w => w.toLowerCase());
}
This handles all common input formats:
myVariableName→['my', 'variable', 'name']MY_CONSTANT→['my', 'constant']some-slug-text→['some', 'slug', 'text']Title Case Words→['title', 'case', 'words']HTMLParser→['html', 'parser']
The 8 Transformations
With clean tokens, the transformations are straightforward:
function capitalize(word: string): string {
return word.charAt(0).toUpperCase() + word.slice(1);
}
const converters: Record<string, (tokens: string[]) => string> = {
camelCase: t => t[0] + t.slice(1).map(capitalize).join(''),
PascalCase: t => t.map(capitalize).join(''),
snake_case: t => t.join('_'),
kebab_case: t => t.join('-'),
SCREAMING_SNAKE: t => t.join('_').toUpperCase(),
'Title Case': t => t.map(capitalize).join(' '),
'Sentence case': t => capitalize(t.join(' ')),
lowercase: t => t.join(' '),
UPPERCASE: t => t.join(' ').toUpperCase(),
};
Converting any input to any format:
function convert(input: string, format: string): string {
const tokens = tokenize(input);
if (tokens.length === 0) return '';
return converters[format](tokens);
}
State
const [input, setInput] = useState('');
const [output, setOutput] = useState<Record<string, string>>({});
useEffect(() => {
if (!input.trim()) {
setOutput({});
return;
}
const tokens = tokenize(input);
const results: Record<string, string> = {};
for (const [format, fn] of Object.entries(converters)) {
results[format] = fn(tokens);
}
setOutput(results);
}, [input]);
All 8 formats update in real-time as the user types. Running all conversions on every keystroke is cheap — tokenization is O(n) on input length and the transforms are O(tokens).
Copy to Clipboard
Each output row has a Copy button. Feedback is a 1.5-second "Copied!" state per row:
const [copied, setCopied] = useState<string | null>(null);
async function copyToClipboard(format: string, value: string) {
try {
await navigator.clipboard.writeText(value);
setCopied(format);
setTimeout(() => setCopied(null), 1500);
} catch {
// Fallback for browsers without clipboard API
const el = document.createElement('textarea');
el.value = value;
document.body.appendChild(el);
el.select();
document.execCommand('copy');
document.body.removeChild(el);
setCopied(format);
setTimeout(() => setCopied(null), 1500);
}
}
{Object.entries(output).map(([format, value]) => (
<div key={format} className="flex items-center gap-3 p-3 rounded-lg bg-zinc-50 dark:bg-zinc-900">
<span className="w-36 text-xs font-mono text-zinc-500">{format}</span>
<span className="flex-1 font-mono text-sm">{value}</span>
<button
onClick={() => copyToClipboard(format, value)}
className="text-xs px-3 py-1 rounded bg-zinc-200 hover:bg-zinc-300"
>
{copied === format ? 'Copied!' : 'Copy'}
</button>
</div>
))}
Edge Cases
Empty input — tokenize('') returns []. The output panel shows nothing.
Single character — 'a' → tokens ['a']. All transforms produce 'a', 'A', etc. Correct.
Numbers — 'version2release' → ['version', '2release']. Numbers don't get split from adjacent lowercase. Good enough for most identifiers.
Acronyms — 'parseHTML' → the regex ([a-z\d])([A-Z]) catches the e→H transition: 'parse HTML'. Then ([A-Z]+)([A-Z][a-z]) catches nothing here since HTML is all-caps. Tokens: ['parse', 'html']. PascalCase output: 'ParseHtml' — loses the all-caps. This is a known limitation.
If acronym preservation matters, you'd need a dictionary of known acronyms. For a general-purpose converter, the simpler approach is correct for 99% of inputs.
Unicode — charAt(0).toUpperCase() works for ASCII. For Unicode strings (über, ñoño), use:
function capitalize(word: string): string {
return word.slice(0, 1).toUpperCase() + word.slice(1);
}
String.prototype.slice is Unicode-safe for most Latin-extended characters. Full Unicode support would require Intl.Segmenter.
The "Live" Format Detection
One useful UX addition: detect what format the input is already in and highlight it:
function detectFormat(input: string): string | null {
if (/^[a-z][a-zA-Z0-9]*$/.test(input) && /[A-Z]/.test(input)) return 'camelCase';
if (/^[A-Z][a-zA-Z0-9]*$/.test(input) && /[a-z]/.test(input)) return 'PascalCase';
if (/^[a-z0-9]+(_[a-z0-9]+)+$/.test(input)) return 'snake_case';
if (/^[a-z0-9]+(-[a-z0-9]+)+$/.test(input)) return 'kebab_case';
if (/^[A-Z0-9]+(_[A-Z0-9]+)+$/.test(input)) return 'SCREAMING_SNAKE';
return null;
}
Show a subtle badge on the row that matches the detected format so the user knows what they pasted.
Putting It Together
The whole converter is ~120 lines: a textarea, a useEffect running all conversions on input change, and a list of output rows with copy buttons. No library needed — pure string manipulation.
