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.