Dismissible chips that vanish with a satisfying poof — click to make them disappear
Click on any chip to make it poof away
Copy URL or Code
Paste to your AI coding assistant and customize:
Done. Your AI handles the rest.
Fully customizable. Smooth fade-and-scale animation with CSS keyframes. Perfect for dismissible tags, filters, or task lists.
"use client";
import React, { useState, useCallback, useRef, useEffect } from "react";
export interface PoofChipsProps {
/** Array of chip labels to display */
chips?: string[];
/** Background color of chips */
chipBackground?: string;
/** Border color of chips */
borderColor?: string;
/** Close button background color */
closeBackground?: string;
/** Text color */
textColor?: string;
/** Border radius of chips */
borderRadius?: number;
/** Gap between chips */
gap?: number;
/** Container width */
containerWidth?: number;
/** Animation duration in ms */
animationDuration?: number;
/** Callback when a chip is removed */
onChipRemove?: (chip: string, index: number) => void;
/** Callback when all chips are removed */
onAllRemoved?: () => void;
/** Allow reset after all removed */
allowReset?: boolean;
/** Force remove a chip by index (for external control/demos) */
forceRemoveIndex?: number | null;
/** Optional className */
className?: string;
}
export default function PoofChips({
chips: initialChips = ["Yeet Me", "Boop", "Later Tater", "Bye Felicia"],
chipBackground = "white",
borderColor = "#ddd",
closeBackground = "#ededed",
textColor = "#333",
borderRadius = 15,
gap = 10,
containerWidth = 300,
animationDuration = 220,
onChipRemove,
onAllRemoved,
allowReset = true,
forceRemoveIndex = null,
className = "",
}: PoofChipsProps) {
const [visibleChips, setVisibleChips] = useState<string[]>(initialChips);
const [removingIndex, setRemovingIndex] = useState<number | null>(null);
// Handle external force remove
useEffect(() => {
if (
forceRemoveIndex !== null &&
forceRemoveIndex >= 0 &&
forceRemoveIndex < visibleChips.length &&
removingIndex === null
) {
handleRemove(forceRemoveIndex);
}
}, [forceRemoveIndex]);
const handleRemove = useCallback(
(index: number) => {
// Prevent double-clicking
if (removingIndex !== null) return;
const chip = visibleChips[index];
// Step 1: Mark as removing (triggers CSS animation)
setRemovingIndex(index);
// Step 2: After animation completes, remove from DOM
setTimeout(() => {
setVisibleChips((prev) => prev.filter((_, i) => i !== index));
setRemovingIndex(null);
onChipRemove?.(chip, index);
if (visibleChips.length === 1) {
onAllRemoved?.();
}
}, animationDuration);
},
[visibleChips, removingIndex, onChipRemove, onAllRemoved, animationDuration]
);
const handleReset = useCallback(() => {
setVisibleChips(initialChips);
}, [initialChips]);
// Generate unique animation name for this instance (stable across renders)
const animationNameRef = useRef(
`vanish-shrink-${Math.random().toString(36).substr(2, 9)}`
);
const animationName = animationNameRef.current;
// Custom easing curve (Material Design inspired)
const animEasing = "cubic-bezier(0.4, 0, 0.2, 1)";
return (
<div className={`vanish-tags-wrap ${className}`}>
{/* Vanish Animation CSS - inject keyframes */}
<style>
{`
@keyframes ${animationName} {
0% {
opacity: 1;
transform: scale(1) translateY(0);
}
100% {
opacity: 0;
transform: scale(0.32) translateY(-4px);
}
}
`}
</style>
<div
className="vanish-tags-grid"
style={{
display: "flex",
flexDirection: "row",
flexWrap: "wrap",
gap: gap,
width: containerWidth,
justifyContent: "center",
}}
>
{visibleChips.map((chip, index) => {
const isRemoving = removingIndex === index;
return (
<div
key={`${chip}-${index}`}
onClick={() => handleRemove(index)}
style={{
background: chipBackground,
border: `1px solid ${borderColor}`,
borderRadius: borderRadius,
padding: "0.3rem 0.5rem 0.3rem 0.75rem",
cursor: isRemoving ? "default" : "pointer",
display: "flex",
alignItems: "center",
gap: "0.5rem",
fontFamily: "system-ui, sans-serif",
color: textColor,
fontSize: "0.9rem",
transition: isRemoving
? "none"
: `transform 0.12s ${animEasing}, box-shadow 0.12s ${animEasing}`,
// Apply animation when removing
animation: isRemoving
? `${animationName} ${animationDuration}ms ${animEasing} forwards`
: "none",
pointerEvents: isRemoving ? "none" : "auto",
}}
onMouseEnter={(e) => {
if (!isRemoving) {
e.currentTarget.style.transform = "scale(1.03)";
e.currentTarget.style.boxShadow = "0 2px 10px rgba(0,0,0,0.1)";
}
}}
onMouseLeave={(e) => {
if (!isRemoving) {
e.currentTarget.style.transform = "scale(1)";
e.currentTarget.style.boxShadow = "none";
}
}}
>
<span>{chip}</span>
<span
style={{
fontFamily: "monospace",
display: "grid",
placeContent: "center",
height: "1.4em",
aspectRatio: "1",
borderRadius: "50%",
background: closeBackground,
fontSize: "0.7rem",
color: "#999",
}}
>
x
</span>
</div>
);
})}
</div>
{/* Reset button when all chips are removed */}
{visibleChips.length === 0 && allowReset && (
<button
onClick={handleReset}
style={{
marginTop: 16,
padding: "0.5rem 1rem",
background: chipBackground,
border: `1px solid ${borderColor}`,
borderRadius: borderRadius,
cursor: "pointer",
fontFamily: "system-ui, sans-serif",
color: textColor,
fontSize: "0.85rem",
transition: `all 0.12s ${animEasing}`,
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = closeBackground;
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = chipBackground;
}}
>
Reset Chips
</button>
)}
</div>
);
}
export { PoofChips };// BASIC: Minimal setup
import PoofChips from "@/components/PoofChips";
export default function MyComponent() {
return (
<PoofChips />
);
}
// CUSTOM LABELS: Your own chip text
export function CustomChips() {
return (
<PoofChips
chips={["React", "Vue", "Svelte", "Angular"]}
/>
);
}
// DARK THEME: Dark mode styling
export function DarkChips() {
return (
<PoofChips
chipBackground="#2a2a2a"
borderColor="#444"
closeBackground="#444"
textColor="#fff"
/>
);
}
// PASTEL THEME: Warm pastel colors
export function PastelChips() {
return (
<PoofChips
chipBackground="#fef3c7"
borderColor="#fcd34d"
closeBackground="#fde68a"
textColor="#92400e"
/>
);
}
// TASK LIST: With removal callbacks
export function TaskChips() {
const handleRemove = (chip: string) => {
console.log(`Completed: ${chip}`);
};
const handleAllDone = () => {
console.log("All tasks completed!");
};
return (
<PoofChips
chips={["Buy groceries", "Walk dog", "Reply emails", "Code review"]}
onChipRemove={handleRemove}
onAllRemoved={handleAllDone}
/>
);
}
// AUTO-DEMO: Programmatic removal every 3 seconds
export function AutoDemo() {
const [removeIdx, setRemoveIdx] = useState<number | null>(null);
const [key, setKey] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setRemoveIdx(0); // Remove first chip
setTimeout(() => setRemoveIdx(null), 300);
}, 3000);
return () => clearInterval(interval);
}, []);
// Reset when all removed
const handleAllRemoved = () => {
setTimeout(() => setKey(k => k + 1), 500);
};
return (
<PoofChips
key={key}
forceRemoveIndex={removeIdx}
onAllRemoved={handleAllRemoved}
allowReset={false}
/>
);
}