Skip to main content
Back to Blog
Tutorials
5 min read
November 29, 2024

How to Build a Tag and Category Management System in React

Create a tag and category management system with CRUD operations, color coding, and hierarchical categories.

Ryel Banfield

Founder & Lead Developer

Content management systems need robust tagging and categorization. Here is how to build a reusable management UI.

Step 1: Data Types

// types/taxonomy.ts
export interface Tag {
  id: string;
  name: string;
  slug: string;
  color: string;
  count: number;
  createdAt: string;
}

export interface Category {
  id: string;
  name: string;
  slug: string;
  description?: string;
  parentId: string | null;
  order: number;
  children?: Category[];
  count: number;
}

Step 2: Tag Manager Component

"use client";

import { useState } from "react";
import { Plus, X, Pencil, Trash2 } from "lucide-react";
import type { Tag } from "@/types/taxonomy";

const TAG_COLORS = [
  "#ef4444", "#f97316", "#eab308", "#22c55e",
  "#06b6d4", "#3b82f6", "#8b5cf6", "#ec4899",
];

function slugify(text: string) {
  return text
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, "-")
    .replace(/(^-|-$)/g, "");
}

export function TagManager() {
  const [tags, setTags] = useState<Tag[]>([]);
  const [newTag, setNewTag] = useState("");
  const [selectedColor, setSelectedColor] = useState(TAG_COLORS[0]);
  const [editingId, setEditingId] = useState<string | null>(null);
  const [editValue, setEditValue] = useState("");
  const [search, setSearch] = useState("");

  function addTag() {
    if (!newTag.trim()) return;
    const slug = slugify(newTag);
    if (tags.some((t) => t.slug === slug)) return;

    setTags((prev) => [
      ...prev,
      {
        id: crypto.randomUUID(),
        name: newTag.trim(),
        slug,
        color: selectedColor,
        count: 0,
        createdAt: new Date().toISOString(),
      },
    ]);
    setNewTag("");
  }

  function deleteTag(id: string) {
    setTags((prev) => prev.filter((t) => t.id !== id));
  }

  function startEdit(tag: Tag) {
    setEditingId(tag.id);
    setEditValue(tag.name);
  }

  function saveEdit(id: string) {
    setTags((prev) =>
      prev.map((t) =>
        t.id === id ? { ...t, name: editValue, slug: slugify(editValue) } : t
      )
    );
    setEditingId(null);
  }

  const filteredTags = tags.filter((t) =>
    t.name.toLowerCase().includes(search.toLowerCase())
  );

  return (
    <div className="space-y-4">
      <div className="flex items-center gap-2">
        <input
          type="text"
          value={search}
          onChange={(e) => setSearch(e.target.value)}
          placeholder="Search tags..."
          className="flex-1 rounded-lg border px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-800"
        />
      </div>

      {/* Add form */}
      <div className="flex items-center gap-2">
        <input
          type="text"
          value={newTag}
          onChange={(e) => setNewTag(e.target.value)}
          onKeyDown={(e) => e.key === "Enter" && addTag()}
          placeholder="New tag name..."
          className="flex-1 rounded-lg border px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-800"
        />
        <div className="flex gap-1">
          {TAG_COLORS.map((color) => (
            <button
              key={color}
              onClick={() => setSelectedColor(color)}
              className={`h-6 w-6 rounded-full border-2 ${
                selectedColor === color ? "border-gray-900 dark:border-white" : "border-transparent"
              }`}
              style={{ backgroundColor: color }}
              aria-label={`Select color ${color}`}
            />
          ))}
        </div>
        <button
          onClick={addTag}
          className="flex items-center gap-1 rounded-lg bg-blue-600 px-3 py-2 text-sm text-white hover:bg-blue-700"
        >
          <Plus className="h-4 w-4" />
          Add
        </button>
      </div>

      {/* Tags list */}
      <div className="flex flex-wrap gap-2">
        {filteredTags.map((tag) => (
          <div
            key={tag.id}
            className="group flex items-center gap-1.5 rounded-full border px-3 py-1 text-sm dark:border-gray-700"
          >
            <span
              className="h-2.5 w-2.5 rounded-full"
              style={{ backgroundColor: tag.color }}
            />
            {editingId === tag.id ? (
              <input
                value={editValue}
                onChange={(e) => setEditValue(e.target.value)}
                onKeyDown={(e) => e.key === "Enter" && saveEdit(tag.id)}
                onBlur={() => saveEdit(tag.id)}
                className="w-24 border-b bg-transparent text-sm focus:outline-none"
                autoFocus
              />
            ) : (
              <span>{tag.name}</span>
            )}
            <span className="text-xs text-gray-400">({tag.count})</span>
            <div className="hidden gap-0.5 group-hover:flex">
              <button onClick={() => startEdit(tag)} aria-label="Edit tag">
                <Pencil className="h-3 w-3 text-gray-400 hover:text-blue-500" />
              </button>
              <button onClick={() => deleteTag(tag.id)} aria-label="Delete tag">
                <Trash2 className="h-3 w-3 text-gray-400 hover:text-red-500" />
              </button>
            </div>
          </div>
        ))}
      </div>

      {filteredTags.length === 0 && (
        <p className="text-center text-sm text-gray-400">No tags found</p>
      )}
    </div>
  );
}

Step 3: Hierarchical Category Manager

"use client";

import { useState } from "react";
import { ChevronRight, ChevronDown, Plus, Pencil, Trash2, GripVertical } from "lucide-react";
import type { Category } from "@/types/taxonomy";

function buildTree(categories: Category[]): Category[] {
  const map = new Map<string, Category>();
  const roots: Category[] = [];

  categories.forEach((cat) => map.set(cat.id, { ...cat, children: [] }));

  map.forEach((cat) => {
    if (cat.parentId) {
      map.get(cat.parentId)?.children?.push(cat);
    } else {
      roots.push(cat);
    }
  });

  return roots.sort((a, b) => a.order - b.order);
}

function CategoryNode({
  category,
  depth,
  onEdit,
  onDelete,
  onAddChild,
}: {
  category: Category;
  depth: number;
  onEdit: (cat: Category) => void;
  onDelete: (id: string) => void;
  onAddChild: (parentId: string) => void;
}) {
  const [expanded, setExpanded] = useState(true);
  const hasChildren = (category.children?.length ?? 0) > 0;

  return (
    <div>
      <div
        className="group flex items-center gap-2 rounded-lg px-2 py-1.5 hover:bg-gray-50 dark:hover:bg-gray-800"
        style={{ paddingLeft: `${depth * 24 + 8}px` }}
      >
        <GripVertical className="h-4 w-4 cursor-grab text-gray-300" />

        <button
          onClick={() => setExpanded(!expanded)}
          className={`h-4 w-4 ${hasChildren ? "" : "invisible"}`}
          aria-label={expanded ? "Collapse" : "Expand"}
        >
          {expanded ? (
            <ChevronDown className="h-4 w-4" />
          ) : (
            <ChevronRight className="h-4 w-4" />
          )}
        </button>

        <span className="flex-1 text-sm font-medium">{category.name}</span>
        <span className="text-xs text-gray-400">{category.count}</span>

        <div className="hidden gap-1 group-hover:flex">
          <button onClick={() => onAddChild(category.id)} aria-label="Add subcategory">
            <Plus className="h-3.5 w-3.5 text-gray-400 hover:text-green-500" />
          </button>
          <button onClick={() => onEdit(category)} aria-label="Edit category">
            <Pencil className="h-3.5 w-3.5 text-gray-400 hover:text-blue-500" />
          </button>
          <button onClick={() => onDelete(category.id)} aria-label="Delete category">
            <Trash2 className="h-3.5 w-3.5 text-gray-400 hover:text-red-500" />
          </button>
        </div>
      </div>

      {expanded &&
        category.children?.map((child) => (
          <CategoryNode
            key={child.id}
            category={child}
            depth={depth + 1}
            onEdit={onEdit}
            onDelete={onDelete}
            onAddChild={onAddChild}
          />
        ))}
    </div>
  );
}

export function CategoryManager() {
  const [categories, setCategories] = useState<Category[]>([]);
  const [newName, setNewName] = useState("");
  const [parentId, setParentId] = useState<string | null>(null);

  function addCategory(overrideParentId?: string) {
    if (!newName.trim()) return;

    const pId = overrideParentId ?? parentId;
    setCategories((prev) => [
      ...prev,
      {
        id: crypto.randomUUID(),
        name: newName.trim(),
        slug: newName.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-"),
        parentId: pId,
        order: prev.filter((c) => c.parentId === pId).length,
        count: 0,
      },
    ]);
    setNewName("");
    setParentId(null);
  }

  function deleteCategory(id: string) {
    // Delete the category and all its descendants
    const idsToDelete = new Set<string>();
    function collectIds(catId: string) {
      idsToDelete.add(catId);
      categories.filter((c) => c.parentId === catId).forEach((c) => collectIds(c.id));
    }
    collectIds(id);
    setCategories((prev) => prev.filter((c) => !idsToDelete.has(c.id)));
  }

  const tree = buildTree(categories);

  return (
    <div className="space-y-4">
      <div className="flex gap-2">
        <input
          type="text"
          value={newName}
          onChange={(e) => setNewName(e.target.value)}
          onKeyDown={(e) => e.key === "Enter" && addCategory()}
          placeholder="New category name..."
          className="flex-1 rounded-lg border px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-800"
        />
        <select
          value={parentId ?? ""}
          onChange={(e) => setParentId(e.target.value || null)}
          className="rounded-lg border px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-800"
        >
          <option value="">Top level</option>
          {categories.map((cat) => (
            <option key={cat.id} value={cat.id}>
              {cat.name}
            </option>
          ))}
        </select>
        <button
          onClick={() => addCategory()}
          className="flex items-center gap-1 rounded-lg bg-blue-600 px-3 py-2 text-sm text-white hover:bg-blue-700"
        >
          <Plus className="h-4 w-4" />
          Add
        </button>
      </div>

      <div className="rounded-xl border dark:border-gray-700">
        {tree.length === 0 ? (
          <p className="p-6 text-center text-sm text-gray-400">No categories yet</p>
        ) : (
          tree.map((cat) => (
            <CategoryNode
              key={cat.id}
              category={cat}
              depth={0}
              onEdit={() => {}}
              onDelete={deleteCategory}
              onAddChild={(pid) => {
                setParentId(pid);
              }}
            />
          ))
        )}
      </div>
    </div>
  );
}

Step 4: Tag Input for Content

"use client";

import { useState, useRef } from "react";
import { X } from "lucide-react";
import type { Tag } from "@/types/taxonomy";

interface TagInputProps {
  availableTags: Tag[];
  selectedTags: Tag[];
  onChange: (tags: Tag[]) => void;
  maxTags?: number;
}

export function TagInput({ availableTags, selectedTags, onChange, maxTags = 10 }: TagInputProps) {
  const [query, setQuery] = useState("");
  const [showSuggestions, setShowSuggestions] = useState(false);
  const inputRef = useRef<HTMLInputElement>(null);

  const suggestions = availableTags.filter(
    (t) =>
      t.name.toLowerCase().includes(query.toLowerCase()) &&
      !selectedTags.some((s) => s.id === t.id)
  );

  function addTag(tag: Tag) {
    if (selectedTags.length >= maxTags) return;
    onChange([...selectedTags, tag]);
    setQuery("");
    setShowSuggestions(false);
    inputRef.current?.focus();
  }

  function removeTag(tagId: string) {
    onChange(selectedTags.filter((t) => t.id !== tagId));
  }

  return (
    <div className="relative">
      <div className="flex flex-wrap gap-1.5 rounded-lg border p-2 focus-within:ring-2 focus-within:ring-blue-500 dark:border-gray-700">
        {selectedTags.map((tag) => (
          <span
            key={tag.id}
            className="flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium text-white"
            style={{ backgroundColor: tag.color }}
          >
            {tag.name}
            <button onClick={() => removeTag(tag.id)} aria-label={`Remove ${tag.name}`}>
              <X className="h-3 w-3" />
            </button>
          </span>
        ))}
        <input
          ref={inputRef}
          type="text"
          value={query}
          onChange={(e) => {
            setQuery(e.target.value);
            setShowSuggestions(true);
          }}
          onFocus={() => setShowSuggestions(true)}
          onBlur={() => setTimeout(() => setShowSuggestions(false), 200)}
          placeholder={selectedTags.length === 0 ? "Add tags..." : ""}
          className="min-w-[100px] flex-1 bg-transparent text-sm focus:outline-none"
        />
      </div>

      {showSuggestions && suggestions.length > 0 && (
        <ul className="absolute z-10 mt-1 max-h-48 w-full overflow-y-auto rounded-lg border bg-white shadow-lg dark:border-gray-700 dark:bg-gray-900">
          {suggestions.map((tag) => (
            <li key={tag.id}>
              <button
                onMouseDown={() => addTag(tag)}
                className="flex w-full items-center gap-2 px-3 py-2 text-sm hover:bg-gray-50 dark:hover:bg-gray-800"
              >
                <span
                  className="h-2.5 w-2.5 rounded-full"
                  style={{ backgroundColor: tag.color }}
                />
                {tag.name}
                <span className="ml-auto text-xs text-gray-400">{tag.count}</span>
              </button>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

Summary

  • Separate tag and category models for flexibility
  • Hierarchical category tree with recursive rendering
  • Inline editing and color coding for tags
  • Reusable TagInput component for content forms

Need a Content Management System?

We build custom CMS solutions tailored to your content workflow. Contact us to get started.

tagscategoriesCRUDReactcontent managementtutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles