Table of Contents
1. Project Overview
A word counter is a deceptively simple yet incredibly useful tool. Writers, students, and professionals need to track word counts, estimate reading time, and analyze keyword usage. We'll build all of this in React.
Features
- Word count — total words and unique words
- Character count — with and without spaces
- Reading time estimation — based on 200 words per minute
- Keyword density — percentage of most frequent words
- Copy to clipboard — one-click copy
- Clear button — reset the text area
- Export results — download stats as a text file
Why build this? Word counters teach fundamental React concepts: controlled components, derived state, memoization, and side effects — all in a practical, real-world context.
2. Setting Up the Project
We'll use Vite for a fast development experience with React.
npm create vite@latest word-counter -- --template react
cd word-counter
npm install
Project Structure
word-counter/
├── src/
│ ├── components/
│ │ ├── TextInput.jsx
│ │ ├── StatsCards.jsx
│ │ ├── KeywordDensity.jsx
│ │ └── ActionBar.jsx
│ ├── hooks/
│ │ └── useTextAnalysis.js
│ ├── utils/
│ │ └── textUtils.js
│ ├── App.jsx
│ ├── App.css
│ └── main.jsx
└── package.json
Text Utility Functions
// src/utils/textUtils.js
export const countWords = (text) => {
if (!text.trim()) return 0;
return text.trim().split(/\s+/).filter(Boolean).length;
};
export const countCharacters = (text) => text.length;
export const countCharactersNoSpaces = (text) => text.replace(/\s/g, '').length;
export const getUniqueWords = (text) => {
const words = text.toLowerCase().match(/\b[a-z']+\b/g) || [];
return new Set(words).size;
};
export const getReadingTime = (wordCount) => {
const wordsPerMinute = 200;
const minutes = Math.ceil(wordCount / wordsPerMinute);
return minutes;
};
export const getKeywordDensity = (text, topN = 5) => {
const words = text.toLowerCase().match(/\b[a-z']+\b/g) || [];
const stopWords = new Set([
'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to',
'for', 'of', 'with', 'by', 'is', 'are', 'was', 'were', 'be',
'been', 'being', 'have', 'has', 'had', 'do', 'does', 'did',
'will', 'would', 'could', 'should', 'may', 'might', 'can',
'this', 'that', 'these', 'those', 'it', 'its', 'i', 'you',
'he', 'she', 'we', 'they', 'me', 'him', 'her', 'us', 'them',
]);
const frequency = {};
words.forEach((word) => {
if (!stopWords.has(word) && word.length > 2) {
frequency[word] = (frequency[word] || 0) + 1;
}
});
const totalFiltered = Object.values(frequency).reduce((a, b) => a + b, 0);
return Object.entries(frequency)
.sort(([, a], [, b]) => b - a)
.slice(0, topN)
.map(([word, count]) => ({
word,
count,
density: ((count / totalFiltered) * 100).toFixed(1),
}));
};
Tip: Separating utility functions from components makes them independently testable and reusable across other projects.
3. Building the Text Input
The text input is a controlled textarea that updates state on every keystroke, providing real-time analysis.
// src/components/TextInput.jsx
import { useRef, useEffect } from 'react';
export default function TextInput({ text, onChange, charLimit = 5000 }) {
const textareaRef = useRef(null);
const remaining = charLimit - text.length;
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
textareaRef.current.style.height = textareaRef.current.scrollHeight + 'px';
}
}, [text]);
return (
<div className="text-input-wrapper">
<textarea
ref={textareaRef}
className="text-input"
value={text}
onChange={(e) => {
if (e.target.value.length <= charLimit) {
onChange(e.target.value);
}
}}
placeholder="Paste or type your text here..."
rows={8}
/>
<div className={`char-counter ${remaining < 50 ? 'warning' : ''} ${remaining < 10 ? 'danger' : ''}`}>
{text.length.toLocaleString()} / {charLimit.toLocaleString()} characters
{remaining < 50 && <span className="remaining"> ({remaining} remaining)</span>}
</div>
</div>
);
}
Performance: For very large texts, consider debouncing the analysis or using useTransition in React 18+ to keep the UI responsive.
4. Implementing Word Count Logic
We'll create a custom hook that derives all text statistics from the current input.
// src/hooks/useTextAnalysis.js
import { useMemo } from 'react';
import {
countWords,
countCharacters,
countCharactersNoSpaces,
getUniqueWords,
getReadingTime,
getKeywordDensity,
} from '../utils/textUtils';
export function useTextAnalysis(text) {
const stats = useMemo(() => {
const wordCount = countWords(text);
const charCount = countCharacters(text);
const charNoSpaces = countCharactersNoSpaces(text);
const uniqueWords = getUniqueWords(text);
const readingTime = getReadingTime(wordCount);
const sentenceCount = text.split(/[.!?]+/).filter(Boolean).length;
const paragraphCount = text.split(/\n\n+/).filter(Boolean).length;
const keywordDensity = getKeywordDensity(text);
return {
wordCount,
charCount,
charNoSpaces,
uniqueWords,
readingTime,
sentenceCount,
paragraphCount,
keywordDensity,
};
}, [text]);
return stats;
}
The useMemo hook ensures expensive calculations only run when the text actually changes, not on every render.
5. Reading Time Calculation
Reading time estimation uses an average reading speed of 200 words per minute for adult readers.
// src/utils/textUtils.js
export const getReadingTime = (wordCount) => {
const wordsPerMinute = 200;
const minutes = Math.ceil(wordCount / wordsPerMinute);
if (minutes === 0) return 'Less than a minute';
if (minutes === 1) return '1 minute';
return `${minutes} minutes`;
};
// For more detailed breakdown
export const getDetailedReadingTime = (wordCount) => {
const wordsPerMinute = 200;
const totalSeconds = Math.ceil((wordCount / wordsPerMinute) * 60);
if (totalSeconds < 60) {
return { minutes: 0, seconds: totalSeconds, display: `${totalSeconds} seconds` };
}
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return {
minutes,
seconds,
display: seconds > 0 ? `${minutes} min ${seconds} sec` : `${minutes} min`,
};
};
Accuracy: The 200 WPM average is for silent reading. Audio narration is typically 150 WPM, and speed readers average 400+ WPM. Adjust based on your audience.
6. Keyword Density Analysis
Keyword density shows which words appear most frequently in your text, excluding common stop words.
KeywordDensity Component
// src/components/KeywordDensity.jsx
export default function KeywordDensity({ keywords }) {
if (keywords.length === 0) return null;
const maxCount = keywords[0]?.count || 1;
return (
<div className="keyword-density">
<h3>Top Keywords</h3>
<div className="keyword-list">
{keywords.map(({ word, count, density }) => (
<div key={word} className="keyword-item">
<div className="keyword-header">
<span className="keyword-word">{word}</span>
<span className="keyword-stats">
{count} times ({density}%)
</span>
</div>
<div className="keyword-bar">
<div
className="keyword-fill"
style={{ width: `${(count / maxCount) * 100}%` }}
/>
</div>
</div>
))}
</div>
</div>
);
}
CSS for Keyword Bars
.keyword-item {
margin-bottom: 0.75rem;
}
.keyword-header {
display: flex;
justify-content: space-between;
margin-bottom: 0.25rem;
}
.keyword-word {
font-weight: 600;
color: #6366f1;
text-transform: capitalize;
}
.keyword-stats {
color: #94a3b8;
font-size: 0.875rem;
}
.keyword-bar {
height: 6px;
background: rgba(99, 102, 241, 0.1);
border-radius: 3px;
overflow: hidden;
}
.keyword-fill {
height: 100%;
background: linear-gradient(90deg, #6366f1, #8b5cf6);
border-radius: 3px;
transition: width 0.3s ease;
}
7. Adding Copy & Clear Buttons
We'll add utility buttons for copying text to clipboard and clearing the input.
// src/components/ActionBar.jsx
import { useState } from 'react';
export default function ActionBar({ text, onClear, stats }) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
// Fallback for older browsers
const textarea = document.createElement('textarea');
textarea.value = text;
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
return (
<div className="action-bar">
<button
className="action-btn copy-btn"
onClick={handleCopy}
disabled={!text.trim()}
>
<i className={`fas ${copied ? 'fa-check' : 'fa-copy'}`}></i>
{copied ? 'Copied!' : 'Copy Text'}
</button>
<button
className="action-btn clear-btn"
onClick={onClear}
disabled={!text.trim()}
>
<i className="fas fa-trash-alt"></i>
Clear
</button>
</div>
);
}
Clipboard API: navigator.clipboard.writeText() is the modern approach but requires HTTPS or localhost. Always provide a fallback using document.execCommand('copy') for compatibility.
8. Styling with CSS
A clean, modern UI with responsive stats cards that adapt to different screen sizes.
/* Stats Cards */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 1rem;
margin: 1.5rem 0;
}
.stat-card {
background: linear-gradient(135deg, #1a1a2e, #16213e);
border-radius: 12px;
padding: 1.25rem;
text-align: center;
border: 1px solid rgba(99, 102, 241, 0.1);
transition: transform 0.2s, border-color 0.2s;
}
.stat-card:hover {
transform: translateY(-2px);
border-color: rgba(99, 102, 241, 0.3);
}
.stat-value {
font-size: 2rem;
font-weight: 800;
color: #6366f1;
line-height: 1;
margin-bottom: 0.25rem;
}
.stat-label {
font-size: 0.8rem;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* Text Input */
.text-input-wrapper {
position: relative;
}
.text-input {
width: 100%;
min-height: 200px;
padding: 1.25rem;
background: #1a1a2e;
border: 2px solid rgba(99, 102, 241, 0.2);
border-radius: 12px;
color: #e2e8f0;
font-size: 1rem;
font-family: 'Inter', sans-serif;
line-height: 1.7;
resize: vertical;
outline: none;
transition: border-color 0.3s;
}
.text-input:focus {
border-color: #6366f1;
}
.text-input::placeholder {
color: #4a5568;
}
.char-counter {
text-align: right;
padding: 0.5rem 0;
font-size: 0.85rem;
color: #94a3b8;
}
.char-counter.warning {
color: #f59e0b;
}
.char-counter.danger {
color: #ef4444;
}
/* Action Buttons */
.action-bar {
display: flex;
gap: 0.75rem;
margin-top: 1rem;
}
.action-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.25rem;
border: none;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.copy-btn {
background: #6366f1;
color: white;
}
.copy-btn:hover {
background: #4f46e5;
}
.clear-btn {
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
border: 1px solid rgba(239, 68, 68, 0.2);
}
.clear-btn:hover {
background: rgba(239, 68, 68, 0.2);
}
.action-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* Mobile Responsive */
@media (max-width: 640px) {
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
.stat-value {
font-size: 1.5rem;
}
.action-bar {
flex-direction: column;
}
.action-btn {
justify-content: center;
}
}
9. Export Results
Finally, let's add the ability to export all analysis results as a downloadable text file.
// src/utils/exportUtils.js
export const exportResults = (text, stats) => {
const report = `
TEXT ANALYSIS REPORT
====================
Generated: ${new Date().toLocaleDateString()}
WORD STATISTICS
---------------
Total Words: ${stats.wordCount}
Unique Words: ${stats.uniqueWords}
Characters: ${stats.charCount}
Characters (no spaces): ${stats.charNoSpaces}
Sentences: ${stats.sentenceCount}
Paragraphs: ${stats.paragraphCount}
Estimated Reading Time: ${stats.readingTime}
TOP KEYWORDS
-------------
${stats.keywordDensity.map((k, i) =>
`${i + 1}. "${k.word}" — ${k.count} times (${k.density}%)`
).join('\n')}
ORIGINAL TEXT
--------------
${text}
`.trim();
const blob = new Blob([report], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `text-analysis-${Date.now()}.txt`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
Using the Export in ActionBar
// Updated ActionBar with export
import { exportResults } from '../utils/exportUtils';
// Add to ActionBar component
<button
className="action-btn export-btn"
onClick={() => exportResults(text, stats)}
disabled={!text.trim()}
>
<i className="fas fa-download"></i>
Export Report
</button>
Tip: Using URL.createObjectURL is more reliable than data URLs for large text blobs. Always revoke the URL after the download to free memory.
This word counter demonstrates how a seemingly simple tool can teach powerful React patterns. The key lessons are deriving state from props, memoizing expensive calculations, and building reusable utility functions. You can extend this further by adding spell checking, sentiment analysis, or readability scores like Flesch-Kincaid.