Forms
TagForge
A tag input at the anvil. Type a word and strike: the hammer lands, sparks fly and it comes off as a glowing steel tag that cools on its hook and swings to rest. Enter or comma forges, Backspace takes the last one down.
Download
- React
- Tailwind CSS v4
- motion
components/ui/tag-forge.tsx
"use client";
import { useRef, useState, type KeyboardEvent } from "react";
import { AnimatePresence, motion, useAnimationControls, useReducedMotion } from "motion/react";
type TagForgeProps = {
/** Read out by screen readers and shown over the bar. */
label?: string;
/** Form field name. Each tag is sent as its own field. */
name?: string;
/** Tags on the rack at start. */
defaultValue?: string[];
/** Most tags the rack takes. */
max?: number;
placeholder?: string;
/** Called with all tags on every change. */
onChange?: (tags: string[]) => void;
/** Set the width here. Everything scales with it. */
className?: string;
};
type Spark = { x: number; y: number; size: number; time: number };
const TAG_SHAPE = "polygon(14% 0, 86% 0, 100% 24%, 100% 100%, 0 100%, 0 24%)";
const makeSparks = () =>
Array.from({ length: 14 }, () => {
const a = -Math.PI * (0.08 + Math.random() * 0.84);
const r = 8 + Math.random() * 24;
return { x: Math.cos(a) * r, y: Math.sin(a) * r, size: 0.4 + Math.random() * 0.5, time: 0.35 + Math.random() * 0.35 };
});
/* TagForge: type a word and strike it. The hammer lands, sparks fly, and the word comes off the anvil as a glowing steel tag that cools on its hook and swings to a stop. A normal tag input underneath: Enter or comma forges, Backspace takes the last one down. */
export function TagForge({ label = "Tags", name, defaultValue = [], max = 6, placeholder = "Type a word", onChange, className = "w-full max-w-lg" }: TagForgeProps) {
const reduce = useReducedMotion();
const hammer = useAnimationControls();
const bar = useAnimationControls();
const input = useRef<HTMLInputElement>(null);
const [tags, setTags] = useState(defaultValue);
const [word, setWord] = useState("");
const [note, setNote] = useState("");
const [sparks, setSparks] = useState<{ id: number; list: Spark[] }>({ id: 0, list: [] });
const update = (next: string[], message: string) => {
setTags(next);
setNote(message);
onChange?.(next);
};
const refuse = (message: string) => {
setNote(message);
if (!reduce) bar.start({ x: [0, -7, 7, -4, 4, 0], transition: { duration: 0.3 } });
};
const forge = () => {
const text = word.trim();
if (!text) return input.current?.focus();
if (tags.some((t) => t.toLowerCase() === text.toLowerCase())) return refuse(`${text} is already on the rack`);
if (tags.length >= max) return refuse(`The rack is full, ${max} tags`);
if (!reduce) {
hammer.start({ rotate: [-34, 12, 8, -34], transition: { duration: 0.6, times: [0, 0.2, 0.3, 1], ease: ["easeIn", "linear", "easeInOut"] } });
setSparks({ id: sparks.id + 1, list: makeSparks() });
}
update([...tags, text], `${text} added`);
setWord("");
input.current?.focus();
};
const remove = (tag: string) => {
update(
tags.filter((t) => t !== tag),
`${tag} removed`,
);
input.current?.focus();
};
const keys = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
forge();
} else if (e.key === "Backspace" && !word && tags.length) {
remove(tags[tags.length - 1]);
}
};
return (
<div
className={`relative @container overflow-hidden rounded-[2cqw] bg-[radial-gradient(120%_70%_at_50%_112%,#a13c0d_0%,#3a1a0c_42%,#14100e_78%)] px-[4cqw] pt-[3cqw] ${className}`}
style={{ aspectRatio: "520 / 320" }}
>
<ul
aria-label={label}
className="flex min-h-[28cqw] flex-wrap content-start gap-x-[2cqw] bg-[linear-gradient(#4a4039_0_0.7cqw,transparent_0.7cqw)] bg-[length:100%_14cqw]"
>
<AnimatePresence initial={false}>
{tags.map((tag) => (
<motion.li
key={tag}
layout={reduce ? false : "position"}
initial={reduce ? false : { y: 26, opacity: 0, rotate: -18 }}
animate={reduce ? { rotate: 0 } : { y: 0, opacity: 1, rotate: [-18, 12, -7, 4, -2, 0] }}
exit={reduce ? { opacity: 0, transition: { duration: 0 } } : { y: 34, opacity: 0, rotate: 24, transition: { duration: 0.3, ease: "easeIn" } }}
transition={{ y: { duration: 0.22 }, opacity: { duration: 0.12 }, rotate: { duration: 1.5, ease: "easeOut" } }}
whileHover={reduce ? undefined : { rotate: 3 }}
className="relative h-[14cqw] origin-[50%_0.4cqw] pt-[3.2cqw] drop-shadow-[0_0.8cqw_0.6cqw_rgba(0,0,0,0.55)]"
>
<span aria-hidden className="absolute top-0 left-1/2 h-[4.6cqw] w-[2.2cqw] -translate-x-1/2 rounded-b-full border-[0.5cqw] border-t-0 border-[#9a9185]" />
<div className="relative flex h-[9.6cqw] items-center gap-[0.8cqw] bg-[linear-gradient(160deg,#d7dade_0%,#9ba1a8_46%,#c5c9ce_100%)] pr-[0.8cqw] pl-[2.2cqw] pt-[1.5cqw]" style={{ clipPath: TAG_SHAPE }}>
<motion.span
aria-hidden
initial={reduce ? false : { opacity: 1 }}
animate={{ opacity: 0 }}
transition={{ duration: 1.8, ease: "easeIn" }}
className="absolute inset-0 bg-[linear-gradient(160deg,#ffe08a,#ff7a1a_55%,#d9360f)]"
/>
<span aria-hidden className="absolute top-[0.9cqw] left-1/2 size-[1.4cqw] -translate-x-1/2 rounded-full bg-[#2a221d]" />
<span className="relative font-mono text-[3.2cqw] font-bold tracking-[0.08em] text-[#23262a] uppercase [text-shadow:0_0.15cqw_0_rgba(255,255,255,0.55)]">{tag}</span>
<button
type="button"
onClick={() => remove(tag)}
aria-label={`Remove ${tag}`}
className="relative grid h-[7.4cqw] w-[6cqw] shrink-0 place-items-center rounded-full font-mono text-[3.2cqw] leading-none text-[#3c4046] transition-colors hover:bg-black/15 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#b5330c]"
>
×
</button>
</div>
</motion.li>
))}
</AnimatePresence>
</ul>
<div className="relative mx-auto mt-[1cqw] w-[82%]">
<div aria-hidden className="pointer-events-none absolute top-[0] left-[72%] z-10">
<AnimatePresence>
{sparks.list.map(
(s, i) => (
<motion.span
key={`${sparks.id}-${i}`}
initial={{ x: 0, y: 0, opacity: 1 }}
animate={{ x: `${s.x}cqw`, y: [`0cqw`, `${s.y}cqw`, `${s.y + 7}cqw`], opacity: 0 }}
transition={{ duration: s.time, delay: 0.12, ease: "easeOut" }}
className="absolute rounded-full bg-[#ffd27a] shadow-[0_0_1cqw_#ff8a2a]"
style={{ width: `${s.size}cqw`, height: `${s.size}cqw` }}
/>
),
)}
</AnimatePresence>
</div>
<motion.div animate={bar} className="relative z-[1]">
<input
ref={input}
value={word}
onChange={(e) => setWord(e.target.value)}
onKeyDown={keys}
aria-label={`${label}, press Enter to add`}
placeholder={placeholder}
maxLength={14}
autoComplete="off"
style={{ outline: "none" }}
className="w-full rounded-[0.8cqw] border-[0.25cqw] border-[#5b5049] bg-[linear-gradient(#332c28,#1f1a17)] py-[1.5cqw] pr-[20cqw] pl-[2.6cqw] font-mono text-[4.2cqw] text-[#ffd9a0] uppercase caret-[#ff8a2a] shadow-[inset_0_-0.4cqw_0_#120e0c] transition-[border-color,box-shadow] placeholder:text-[#a18d7c] placeholder:normal-case focus:border-[#ff8a2a] focus:shadow-[inset_0_-0.4cqw_0_#120e0c,0_0_3cqw_-0.4cqw_#ff7a1a] focus:outline-none"
/>
</motion.div>
{/* The anvil under the bar. */}
<svg aria-hidden viewBox="0 0 430 60" className="relative mx-auto -mt-[0.4cqw] w-full">
<path d="M4 0H392C412 2 424 9 428 16C400 18 340 18 322 24C310 30 306 40 318 50H334V60H104V50H122C134 40 126 28 106 24C70 17 30 12 4 0Z" fill="#26211e" />
<path d="M4 0H392C412 2 424 9 428 16" fill="none" stroke="#6a5e55" strokeWidth="1.4" />
</svg>
<motion.button
type="button"
onClick={forge}
aria-label="Forge tag"
initial={{ rotate: -34 }}
animate={hammer}
style={{ transformOrigin: "88% 82%" }}
className="absolute right-[1%] -top-[11cqw] z-20 w-[24cqw] rounded-[1cqw] focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[#ffb36b]"
>
<svg aria-hidden viewBox="0 0 120 84" className="w-full drop-shadow-[0_0.6cqw_0.6cqw_rgba(0,0,0,0.5)]">
<rect x="26" y="34" width="92" height="9" rx="4" fill="#8a5a33" />
<rect x="26" y="34" width="92" height="3" rx="1.5" fill="#a97345" />
<path d="M4 18H34a4 4 0 0 1 4 4V56a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V22a4 4 0 0 1 4-4Z" fill="#5a616a" />
<path d="M4 18H34a4 4 0 0 1 4 4v6H0v-6a4 4 0 0 1 4-4Z" fill="#7d858f" />
</svg>
</motion.button>
</div>
{name && tags.map((tag) => <input key={tag} type="hidden" name={name} value={tag} />)}
<p aria-live="polite" className="sr-only">
{note}
</p>
</div>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/tag-forge.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/tag-forge-demo.tsx
import { TagForge } from "@/components/ui/tag-forge"; export default function TagForgeExample() { return ( <div className="@container-size grid h-full place-items-center overflow-hidden bg-[#0d0a09] p-2"> <TagForge className="w-[min(150cqh,94cqw,560px)]" defaultValue={["Handmade", "Madurai"]} /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| label | string | "Tags" | Read out by screen readers. |
| name | string | - | Form field name. Each tag is sent as its own field. |
| defaultValue | string[] | [] | Tags on the rack at start. |
| max | number | 6 | Most tags the rack takes. |
| placeholder | string | "Type a word" | Shown on the empty bar. |
| onChange | (tags: string[]) => void | - | Called with all tags on every change. |
| className | string | "w-full max-w-lg" | Set the width here. Everything scales with it. |