Creative animated dropdown select with 8 unique visual styles — add to your site in seconds
Copy URL or Code
Paste to your AI coding assistant and customize:
Done. Your AI handles the rest.
Fully customizable. 8 unique animation styles: Border, Underline, Elastic, Slide, Overlay, Rotate, Box, and Circular. Supports keyboard navigation and accessibility.
/*
================================================================================
AI COMPONENT: AnimatedSelect
================================================================================
QUICK START FOR AI ASSISTANTS (Claude Code, Cursor, Codex):
1. Create file: components/AnimatedSelect.tsx
2. Paste this entire code block
3. Import: import AnimatedSelect from "@/components/AnimatedSelect"
================================================================================
PROPS - All optional except options
================================================================================
| Prop | Type | Default | What it does |
|-------------|----------------|--------------------|---------------------------------|
| options | SelectOption[] | required | Array of option objects |
| value | string | undefined | Currently selected value |
| onChange | (v: string)=>void | undefined | Callback when selection changes |
| placeholder | string | "Select an option" | Placeholder text |
| skin | SelectSkin | "border" | Visual style variant |
| className | string | "" | Additional CSS classes |
| disabled | boolean | false | Disable the select |
SelectOption properties:
- value: string (required) - The value passed to onChange
- label: string (required) - Display text for the option
- disabled?: boolean - Disable this specific option
- icon?: React.ReactNode - Icon for overlay skin (shows above label)
- image?: string - Image URL for circular skin (fills circle)
================================================================================
AVAILABLE SKINS
================================================================================
| Skin | Description |
|-----------|----------------------------------------------------------|
| border | Classic thick border with color change on open |
| underline | Minimal bottom border with slide-in options |
| elastic | Bouncy scale animation for options list |
| slide | Dark theme with sliding background effect |
| overlay | Full-screen overlay with icons (use icon prop) |
| rotate | Rotation animation on dropdown |
| box | Gradient box with shadow and borders |
| circular | Options fan out in circle with images (use image prop) |
================================================================================
COMMON CUSTOMIZATION REQUESTS
================================================================================
USER ASKS: "Use the elastic style"
→ Set skin="elastic": <AnimatedSelect skin="elastic" options={...} />
USER ASKS: "Change the placeholder text"
→ Set placeholder: <AnimatedSelect placeholder="Choose one..." options={...} />
USER ASKS: "Make it controlled"
→ Use value and onChange:
const [selected, setSelected] = useState("");
<AnimatedSelect value={selected} onChange={setSelected} options={...} />
USER ASKS: "Disable an option"
→ Add disabled: true to the option object:
{ value: "opt1", label: "Option 1", disabled: true }
================================================================================
SOURCE CODE
================================================================================
*/
"use client";
import React, { useState, useRef, useEffect, useCallback } from "react";
export type SelectSkin =
| "border"
| "underline"
| "elastic"
| "slide"
| "overlay"
| "rotate"
| "box"
| "circular";
export interface SelectOption {
value: string;
label: string;
disabled?: boolean;
icon?: React.ReactNode; // For overlay skin - renders icon above label
image?: string; // For circular skin - renders image in circle
}
export interface AnimatedSelectProps {
options: SelectOption[];
value?: string;
onChange?: (value: string) => void;
placeholder?: string;
skin?: SelectSkin;
className?: string;
disabled?: boolean;
}
export default function AnimatedSelect({
options,
value,
onChange,
placeholder = "Select an option",
skin = "border",
className = "",
disabled = false,
}: AnimatedSelectProps) {
const [isOpen, setIsOpen] = useState(false);
const [focusedIndex, setFocusedIndex] = useState(-1);
const containerRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLUListElement>(null);
const selectedOption = options.find((opt) => opt.value === value);
const handleToggle = () => {
if (disabled) return;
setIsOpen(!isOpen);
if (!isOpen) {
setFocusedIndex(options.findIndex((opt) => opt.value === value));
}
};
const handleSelect = (option: SelectOption) => {
if (option.disabled) return;
onChange?.(option.value);
setIsOpen(false);
};
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (disabled) return;
switch (e.key) {
case "Enter":
case " ":
e.preventDefault();
if (isOpen && focusedIndex >= 0) {
const option = options[focusedIndex];
if (!option.disabled) {
handleSelect(option);
}
} else {
setIsOpen(true);
}
break;
case "Escape":
setIsOpen(false);
break;
case "ArrowDown":
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
} else {
setFocusedIndex((prev) => {
let next = prev + 1;
while (next < options.length && options[next].disabled) next++;
return next < options.length ? next : prev;
});
}
break;
case "ArrowUp":
e.preventDefault();
if (isOpen) {
setFocusedIndex((prev) => {
let next = prev - 1;
while (next >= 0 && options[next].disabled) next--;
return next >= 0 ? next : prev;
});
}
break;
}
},
[disabled, isOpen, focusedIndex, options]
);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (
containerRef.current &&
!containerRef.current.contains(e.target as Node)
) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const getSkinStyles = () => {
const base = {
container: "relative inline-block w-full max-w-[300px]",
trigger: "w-full cursor-pointer transition-all duration-300",
options:
"absolute w-full transition-all duration-300 overflow-hidden z-50",
option: "cursor-pointer transition-all duration-200",
};
switch (skin) {
case "border":
return {
container: `${base.container}`,
trigger: `${base.trigger} px-4 py-3 border-[3px] border-gray-800 bg-transparent text-gray-800 font-bold text-lg ${
isOpen ? "bg-white border-blue-500 text-blue-600" : ""
}`,
options: `${base.options} mt-0 border-[3px] border-t-0 border-gray-800 bg-white ${
isOpen
? "opacity-100 visible max-h-[300px]"
: "opacity-0 invisible max-h-0"
}`,
option: `${base.option} px-4 py-3 hover:bg-gray-100`,
};
case "underline":
return {
container: `${base.container}`,
trigger: `${base.trigger} px-2 py-2 border-b-[3px] border-gray-700 bg-transparent text-gray-800 font-medium text-lg`,
options: `${base.options} mt-2 bg-[#bbc7c8] rounded ${
isOpen
? "opacity-100 visible max-h-[300px]"
: "opacity-0 invisible max-h-0"
}`,
option: `${base.option} px-4 py-2 uppercase text-xs tracking-widest text-gray-700 hover:text-gray-900 ${
isOpen ? "translate-x-0" : "translate-x-8"
}`,
};
case "elastic":
return {
container: `${base.container}`,
trigger: `${base.trigger} px-4 py-3 bg-white text-[#5b8583] font-bold rounded shadow-md`,
options: `${base.options} mt-1 bg-white rounded shadow-lg origin-top ${
isOpen
? "opacity-100 visible scale-y-100"
: "opacity-0 invisible scale-y-0"
}`,
option: `${base.option} px-4 py-3 text-[#5b8583] hover:text-[#1e4c4a] ${
isOpen ? "translate-y-0 opacity-100" : "-translate-y-4 opacity-0"
}`,
};
case "slide":
return {
container: `${base.container}`,
trigger: `${base.trigger} px-4 py-4 bg-[#282b30] text-white font-medium overflow-hidden relative`,
options: `${base.options} top-0 left-0 h-full flex flex-col justify-center items-center bg-[#282b30] ${
isOpen
? "opacity-100 visible scale-x-100"
: "opacity-0 invisible scale-x-0"
}`,
option: `${base.option} px-4 py-2 text-white uppercase text-xs tracking-widest hover:text-[#eb7e7f] ${
isOpen ? "translate-x-0 opacity-100" : "translate-x-8 opacity-0"
}`,
};
case "overlay":
return {
container: `${base.container}`,
trigger: `${base.trigger} px-4 py-3 bg-white border border-gray-200 rounded shadow-sm text-gray-800`,
options: `fixed inset-0 bg-white/95 flex flex-wrap justify-center items-center p-8 ${
isOpen
? "opacity-100 visible"
: "opacity-0 invisible pointer-events-none"
}`,
option: `${base.option} w-1/2 p-4 text-gray-600 hover:text-[#f06d54] text-center ${
isOpen ? "translate-y-0 opacity-100" : "translate-y-4 opacity-0"
}`,
};
case "rotate":
return {
container: `${base.container}`,
trigger: `${base.trigger} px-4 py-3 bg-[#1a1a2e] text-white font-medium rounded`,
options: `${base.options} mt-1 bg-[#1a1a2e] rounded origin-top ${
isOpen
? "opacity-100 visible rotate-0"
: "opacity-0 invisible -rotate-12"
}`,
option: `${base.option} px-4 py-3 text-white/80 hover:text-white hover:bg-white/10 ${
isOpen ? "translate-y-0 opacity-100" : "-translate-y-2 opacity-0"
}`,
};
case "box":
return {
container: `${base.container}`,
trigger: `${base.trigger} px-4 py-3 bg-gradient-to-b from-gray-50 to-gray-100 border border-gray-300 rounded-lg shadow-sm text-gray-800`,
options: `${base.options} mt-1 bg-white border border-gray-200 rounded-lg shadow-lg ${
isOpen
? "opacity-100 visible max-h-[300px]"
: "opacity-0 invisible max-h-0"
}`,
option: `${base.option} px-4 py-3 hover:bg-blue-50 hover:text-blue-600 border-b border-gray-100 last:border-b-0`,
};
case "circular":
return {
container: `${base.container} ${isOpen ? "z-[100]" : ""}`,
trigger: `${base.trigger} px-4 py-3 bg-[#e74c3c] text-white font-bold rounded-full text-center`,
options: `absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 ${
isOpen ? "opacity-100 visible" : "opacity-0 invisible"
}`,
option: `absolute px-3 py-2 bg-[#e74c3c] text-white rounded-full text-sm whitespace-nowrap hover:bg-[#c0392b] shadow-lg`,
};
default:
return base;
}
};
const styles = getSkinStyles();
const renderCircularOptions = () => {
const radius = 100;
const startAngle = -90;
const angleStep = 360 / options.length;
return options.map((option, index) => {
const angle = (startAngle + index * angleStep) * (Math.PI / 180);
const x = Math.cos(angle) * radius;
const y = Math.sin(angle) * radius;
return (
<li
key={option.value}
className={`${styles.option} ${
option.value === value ? "ring-2 ring-white" : ""
} ${focusedIndex === index ? "bg-[#c0392b]" : ""}`}
style={{
transform: isOpen ? `translate(${x}px, ${y}px)` : "translate(0, 0)",
transitionDelay: isOpen ? `${index * 50}ms` : "0ms",
opacity: isOpen ? 1 : 0,
}}
onClick={() => handleSelect(option)}
>
{option.label}
</li>
);
});
};
return (
<div
ref={containerRef}
className={`${styles.container} ${className}`}
onKeyDown={handleKeyDown}
tabIndex={disabled ? -1 : 0}
role="listbox"
aria-expanded={isOpen}
aria-disabled={disabled}
>
<div
className={`${styles.trigger} ${disabled ? "opacity-50 cursor-not-allowed" : ""}`}
onClick={handleToggle}
aria-haspopup="listbox"
>
<span className="flex items-center justify-between">
<span className={!selectedOption ? "text-gray-400" : ""}>
{selectedOption?.label || placeholder}
</span>
<svg
className={`w-4 h-4 transition-transform duration-300 ${
isOpen ? "rotate-180" : ""
}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</span>
</div>
{skin === "circular" ? (
<ul ref={listRef} className={styles.options}>
{renderCircularOptions()}
</ul>
) : skin === "overlay" ? (
<div
className={styles.options}
onClick={(e) => {
if (e.target === e.currentTarget) setIsOpen(false);
}}
>
<button
className="absolute top-4 right-4 text-gray-400 hover:text-gray-600 text-2xl"
onClick={() => setIsOpen(false)}
>
×
</button>
<ul className="flex flex-wrap justify-center max-w-lg">
{options.map((option, index) => (
<li
key={option.value}
className={`${styles.option} ${
option.value === value ? "text-[#f06d54] font-bold" : ""
} ${focusedIndex === index ? "bg-gray-100" : ""} ${
option.disabled ? "opacity-40 cursor-not-allowed" : ""
}`}
style={{
transitionDelay: isOpen ? `${index * 30}ms` : "0ms",
}}
onClick={() => handleSelect(option)}
role="option"
aria-selected={option.value === value}
>
{option.label}
</li>
))}
</ul>
</div>
) : (
<ul ref={listRef} className={styles.options}>
{options.map((option, index) => (
<li
key={option.value}
className={`${styles.option} ${
option.value === value
? skin === "border"
? "bg-gray-100 font-medium"
: "font-bold"
: ""
} ${focusedIndex === index ? "bg-gray-100" : ""} ${
option.disabled ? "opacity-40 cursor-not-allowed" : ""
}`}
style={{
transitionDelay: isOpen ? `${index * 30}ms` : "0ms",
}}
onClick={() => handleSelect(option)}
role="option"
aria-selected={option.value === value}
>
<span className="flex items-center justify-between">
{option.label}
{option.value === value && (
<svg
className="w-4 h-4 text-green-500"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
)}
</span>
</li>
))}
</ul>
)}
</div>
);
}
export { AnimatedSelect };import AnimatedSelect from "@/components/AnimatedSelect";
import { useState } from "react";
// Basic usage with border style (default)
function ContactForm() {
const [contact, setContact] = useState("");
return (
<AnimatedSelect
options={[
{ value: "email", label: "E-Mail" },
{ value: "phone", label: "Phone" },
{ value: "twitter", label: "Twitter" },
]}
value={contact}
onChange={setContact}
placeholder="Preferred contact method"
/>
);
}
// Elastic bounce effect
function ElasticSelect() {
const [selected, setSelected] = useState("");
return (
<AnimatedSelect
skin="elastic"
options={[
{ value: "small", label: "Small" },
{ value: "medium", label: "Medium" },
{ value: "large", label: "Large" },
]}
value={selected}
onChange={setSelected}
placeholder="Select size"
/>
);
}
// Dark slide style
function SlideSelect() {
const [category, setCategory] = useState("");
return (
<div className="bg-gray-900 p-8">
<AnimatedSelect
skin="slide"
options={[
{ value: "photos", label: "Photos" },
{ value: "videos", label: "Videos" },
{ value: "documents", label: "Documents" },
]}
value={category}
onChange={setCategory}
placeholder="File type"
/>
</div>
);
}
// Circular radial menu
function CircularSelect() {
const [action, setAction] = useState("");
return (
<div className="relative h-64 flex items-center justify-center">
<AnimatedSelect
skin="circular"
options={[
{ value: "edit", label: "Edit" },
{ value: "share", label: "Share" },
{ value: "delete", label: "Delete" },
{ value: "archive", label: "Archive" },
]}
value={action}
onChange={setAction}
placeholder="Actions"
/>
</div>
);
}
// Full-screen overlay
function OverlaySelect() {
const [country, setCountry] = useState("");
return (
<AnimatedSelect
skin="overlay"
options={[
{ value: "us", label: "United States" },
{ value: "uk", label: "United Kingdom" },
{ value: "ca", label: "Canada" },
{ value: "au", label: "Australia" },
{ value: "de", label: "Germany" },
{ value: "fr", label: "France" },
]}
value={country}
onChange={setCountry}
placeholder="Select country"
/>
);
}
// With disabled option
function SelectWithDisabled() {
const [plan, setPlan] = useState("");
return (
<AnimatedSelect
skin="box"
options={[
{ value: "free", label: "Free Plan" },
{ value: "pro", label: "Pro Plan" },
{ value: "enterprise", label: "Enterprise", disabled: true },
]}
value={plan}
onChange={setPlan}
placeholder="Choose plan"
/>
);
}