Badge Overflow Detection¶
Navigation: Home → Features & Components → Badge Overflow
Table of Contents¶
- Introduction
- The Problem
- The Solution
- Algorithm Implementation
- Overflow Detection Logic
- Performance Optimizations
- Tooltip Integration
- Responsive Behavior
- Usage Examples
- See Also
- Next Steps
Introduction¶
The BadgeOverflow component (components/badge-overflow.tsx) dynamically displays technology badges with intelligent overflow handling. When badges exceed available width, it automatically hides overflow items and displays a "+N more" indicator with a tooltip showing hidden technologies.
Key Features¶
- ✅ Dynamic Overflow Detection: Automatically calculates which badges fit
- ✅ Responsive: Adjusts on window resize
- ✅ Performance Optimized: Uses
requestAnimationFrameand efficient DOM measurements - ✅ Tooltip Feedback: Shows hidden technologies on hover
- ✅ Graceful Degradation: Works without JavaScript (shows all badges)
- ✅ Accessibility: Proper ARIA labels and tooltip roles
The Problem¶
Fixed-Width Containers¶
Project cards have fixed widths, but technology lists vary in length:
Card Width: 350px
Short list: [React] [TypeScript] ← Fits fine
Long list: [React] [TypeScript] [Next.js] [Tailwind] [PostgreSQL] [Docker] [AWS]
← Wraps to multiple lines or overflows!
Issues Without Overflow Handling¶
- Visual Chaos: Badges wrap to multiple lines, breaking card layout
- Inconsistent Heights: Cards become different sizes
- Information Overload: Too many badges distract from key info
- Poor UX: No indication of hidden technologies
The Solution¶
Visual Representation¶
Before Overflow Detection:
┌──────────────────────────────────┐
│ [React] [TypeScript] [Next.js] │
│ [Tailwind] [PostgreSQL] [Docker] │ ← Wrapped to 2 lines
│ [AWS] │
└──────────────────────────────────┘
After Overflow Detection:
┌──────────────────────────────────┐
│ [React] [TypeScript] [Next.js] │
│ +4 more ← Tooltip │ ← Single line, compact
└──────────────────────────────────┘
Algorithm Overview¶
1. Measure container width
2. Measure each badge width
3. Calculate cumulative width + gap
4. Hide badges that exceed (containerWidth - 60px)
5. Display "+N more" indicator
6. Attach tooltip with hidden tech names
Algorithm Implementation¶
Component Structure¶
```typescript:1:93:components/badge-overflow.tsx "use client";
import { useEffect, useRef, useState } from "react"; import { Badge } from "@/components/ui/badge"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils";
interface BadgeOverflowProps { technologies: string[]; className?: string; }
export default function BadgeOverflow({ technologies, className }: BadgeOverflowProps) {
const containerRef = useRef
useEffect(() => { const checkOverflow = () => { if (!containerRef.current || !countRef.current) return;
// Reset badge visibility
const badges = containerRef.current.querySelectorAll("li[data-tech]");
badges.forEach((badge) => ((badge as HTMLElement).style.display = "inline-block"));
// Hide count badge initially
countRef.current.style.display = "none";
// Measure container
const containerWidth = containerRef.current.getBoundingClientRect().width;
const hidden: string[] = [];
// Calculate which badges fit
let currentWidth = 0;
badges.forEach((badge, i) => {
const badgeWidth = badge.getBoundingClientRect().width + 8; // 8px for gap
const tech = technologies[i];
// Check if this badge would overflow (reserve 60px for count badge)
if (currentWidth + badgeWidth > containerWidth - 60) {
(badge as HTMLElement).style.display = "none";
hidden.push(tech);
} else {
currentWidth += badgeWidth;
}
});
// Update hidden techs and count badge
setHiddenTechs(hidden);
if (hidden.length > 0) {
countRef.current.textContent = `+${hidden.length} more`;
countRef.current.style.display = "inline";
}
};
// Run after DOM update
requestAnimationFrame(checkOverflow);
window.addEventListener("resize", checkOverflow);
return () => window.removeEventListener("resize", checkOverflow);
}, [technologies]);
return (
-
{technologies.map((tech) => (
- {tech}
- Window resize may allow more badges to fit
- Technologies array may change
- Ensures fresh calculation each time
- Padding
- Borders
- Sub-pixel rendering
+ 8: CSS gap ofgap-2(0.5rem = 8px) between badges- 60: Reserved space for "+N more" indicator (conservative estimate)- Direct DOM manipulation is fastest
- No need for re-render/reconciliation
- Clean separation from CSS classes
- Runs after DOM paint
- Ensures accurate measurements
- Batches with browser rendering cycle
- Prevents layout thrashing
- Initial render (refs not yet attached)
- Component unmounting
- Conditional rendering
cursor-help: Indicates additional info availablerole="tooltip": Semantic role for screen readersaria-label: Describes purposedata-count: Selector for styling/testingoverflow-hidden: Clips overflow (no scrollbar)whitespace-nowrap: Prevents text wrapping within badgesflex: Enables flexible layoutgap-2: 8px spacing between items
{/* "+x more" badge with tooltip */}
<Tooltip>
<TooltipTrigger asChild>
<li
className="text-muted-foreground cursor-help text-xs text-nowrap"
style={{ display: "none" }}
ref={countRef}
role="tooltip"
aria-label="Additional technologies"
data-count
/>
</TooltipTrigger>
<TooltipContent>
<span>{hiddenTechs.length > 0 ? hiddenTechs.join(", ") : "Additional technologies"}</span>
</TooltipContent>
</Tooltip>
</ul>
); } ```
Overflow Detection Logic¶
Step-by-Step Breakdown¶
1. Initialization & Refs¶
typescript
const containerRef = useRef<HTMLUListElement>(null); // Reference to <ul>
const countRef = useRef<HTMLLIElement>(null); // Reference to "+N more" element
const [hiddenTechs, setHiddenTechs] = useState<string[]>([]); // State for tooltip
2. Reset State¶
const badges = containerRef.current.querySelectorAll("li[data-tech]");
badges.forEach((badge) => ((badge as HTMLElement).style.display = "inline-block"));
countRef.current.style.display = "none";
Why Reset?
3. Measure Container¶
Uses getBoundingClientRect() for accurate pixel measurements including:
4. Calculate Badge Widths¶
let currentWidth = 0;
badges.forEach((badge, i) => {
const badgeWidth = badge.getBoundingClientRect().width + 8; // 8px for gap
const tech = technologies[i];
if (currentWidth + badgeWidth > containerWidth - 60) {
// Would overflow!
} else {
currentWidth += badgeWidth;
}
});
Magic Numbers Explained:
5. Hide Overflowing Badges¶
if (currentWidth + badgeWidth > containerWidth - 60) {
(badge as HTMLElement).style.display = "none";
hidden.push(tech);
}
Why inline styles?
6. Update Count Badge¶
setHiddenTechs(hidden);
if (hidden.length > 0) {
countRef.current.textContent = `+${hidden.length} more`;
countRef.current.style.display = "inline";
}
Performance Optimizations¶
requestAnimationFrame¶
Benefits:
Alternative (worse):
Resize Debouncing¶
Current Implementation:
Enhancement Opportunity:
const debouncedResize = debounce(checkOverflow, 150);
window.addEventListener("resize", debouncedResize);
Reduces calculations during continuous resize events.
Early Return¶
Prevents errors during:
Cleanup¶
Prevents memory leaks by removing event listener on unmount.
Tooltip Integration¶
Tooltip Structure¶
<Tooltip>
<TooltipTrigger asChild>
<li
className="text-muted-foreground cursor-help text-xs text-nowrap"
style={{ display: "none" }}
ref={countRef}
role="tooltip"
aria-label="Additional technologies"
data-count
/>
</TooltipTrigger>
<TooltipContent>
<span>{hiddenTechs.length > 0 ? hiddenTechs.join(", ") : "Additional technologies"}</span>
</TooltipContent>
</Tooltip>
Tooltip Content¶
Visible Badges: React, TypeScript, Next.js
Hidden Badges: Tailwind CSS, PostgreSQL, Docker, AWS
Tooltip Text: "Tailwind CSS, PostgreSQL, Docker, AWS"
Accessibility Features¶
Responsive Behavior¶
Container Constraints¶
Window Resize Handling¶
flowchart TD
Resize[Window Resize Event] --> CheckOverflow[checkOverflow Function]
CheckOverflow --> Measure[Measure Container Width]
Measure --> Reset[Reset All Badges to Visible]
Reset --> Calculate{For Each Badge}
Calculate -->|Fits| Show[Keep Visible]
Calculate -->|Overflow| Hide[Hide Badge]
Show --> Calculate
Hide --> Calculate
Calculate -->|Done| Update[Update Count Badge]
Update --> Display[Update Display]
style Resize fill:#e1f5ff
style Display fill:#d4edda
Responsive Breakpoints¶
The component adapts to container size, not screen breakpoints:
// Works at any container width
<div className="w-full md:w-1/2 lg:w-1/3">
<BadgeOverflow technologies={techs} />
</div>
Usage Examples¶
In Project Card¶
```typescript:14:14:components/project-card.tsx import BadgeOverflow from "@/components/badge-overflow";
```typescript:56:56:components/project-card.tsx
<BadgeOverflow technologies={project.technologies} />
Full context:
<CardContent className="-my-2">
<BadgeOverflow technologies={project.technologies} />
</CardContent>
Standalone Usage¶
import BadgeOverflow from "@/components/badge-overflow";
const technologies = ["React", "TypeScript", "Next.js", "Tailwind CSS", "PostgreSQL"];
<div className="max-w-sm rounded-lg border p-4">
<h3 className="mb-2 font-bold">Technologies</h3>
<BadgeOverflow technologies={technologies} />
</div>
With Custom Styling¶
<BadgeOverflow
technologies={project.technologies}
className="mb-4 justify-center" // Custom classes
/>
Dynamic Technologies¶
const [techs, setTechs] = useState<string[]>([]);
useEffect(() => {
// Fetch or compute technologies
setTechs(computedTechnologies);
}, []);
<BadgeOverflow technologies={techs} />
The component automatically recalculates when technologies prop changes.
See Also¶
- Project Card Component - Primary usage
- Badge Component - shadcn/ui badge
- Tooltip Component - shadcn/ui tooltip
- Skills Table - Technology display in table format
Next Steps¶
- Debounce Resize: Add debouncing to reduce resize calculations
- Intersection Observer: Only calculate when component is visible
- Server Hints: Pre-calculate on server for initial render
- CSS-Only Fallback: Use CSS text-overflow as fallback
- Customizable Reserve: Make the 60px reserve configurable
- Animation: Add smooth transitions when badges hide/show
- Testing: Add unit tests for overflow calculations
Enhanced Implementation¶
import { debounce } from "lodash";
export default function BadgeOverflow({
technologies,
className,
reserveWidth = 60, // Configurable reserve
debounceMs = 150, // Configurable debounce
}: BadgeOverflowProps) {
// ... existing code ...
useEffect(() => {
const debouncedCheck = debounce(checkOverflow, debounceMs);
requestAnimationFrame(checkOverflow);
window.addEventListener("resize", debouncedCheck);
return () => {
debouncedCheck.cancel();
window.removeEventListener("resize", debouncedCheck);
};
}, [technologies, reserveWidth]);
// Use reserveWidth instead of hardcoded 60
}
Last Updated: 2024-02-09
Related Docs: Components | Project Card | Directory Structure