Back to Blog

React Word Counter Project

Build a powerful word counter app in React with reading time, keyword density, and export features.

React Word Counter Project

A practical React project that analyzes text with word count, reading time, and keyword density

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.

Back to Top