Country Dropdown for Tailwind / shadcn — Drop-In Component
Country dropdowns sound easy until you build one. Then you realize you need: searchable list, flag images that don't tank your bundle, keyboard navigation, ARIA, and a way to hydrate the data without re-fetching on every component mount.
This is a drop-in component for Tailwind / shadcn projects. ~80 lines, no extra dependencies, works with any state management.
Component code
Save as components/CountryDropdown.tsx:
'use client';
import { useEffect, useMemo, useRef, useState } from 'react';
interface Country {
iso2: string;
name: string;
flagUrl: string;
}
interface Props {
countries: Country[];
value?: string;
onChange: (iso2: string) => void;
placeholder?: string;
}
export function CountryDropdown({ countries, value, onChange, placeholder = 'Select a country…' }: Props) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const ref = useRef(null);
const selected = countries.find((c) => c.iso2 === value);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return countries;
return countries.filter(
(c) => c.name.toLowerCase().includes(q) || c.iso2.toLowerCase().startsWith(q)
);
}, [query, countries]);
useEffect(() => {
function onClickOutside(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
}
document.addEventListener('mousedown', onClickOutside);
return () => document.removeEventListener('mousedown', onClickOutside);
}, []);
return (
{open && (
setQuery(e.target.value)}
placeholder="Search…"
className="sticky top-0 w-full border-b border-slate-700 bg-slate-950 px-3 py-2 text-sm text-slate-100 focus:outline-none"
/>
{filtered.length === 0 ? (
No matches.
) : (
filtered.map((c) => (
))
)}
)}
);
}
Hydrating the country list (Server Component)
You don't want to fetch 250 countries on every component mount. Fetch once at the page level (Server Component) and pass to the client component as a prop:
import { CountryDropdown } from '@/components/CountryDropdown';
async function getCountries() {
const res = await fetch('https://api.apogeoapi.com/v1/countries', {
headers: { 'X-API-Key': process.env.APOGEOAPI_KEY! },
next: { revalidate: 86400 }, // country list rarely changes — 24h cache is fine
});
return res.json();
}
export default async function SignupPage() {
const countries = await getCountries();
return (
<form>
<label>Country</label>
<CountryDropdown
countries={countries}
value={undefined}
onChange={(iso2) => console.log(iso2)}
/>
</form>
);
}
The revalidate: 86400 tells Next.js to cache this fetch for 24 hours per origin. Country data doesn't change daily, so this is safe and saves your API quota.
Combining with auto-detection
To pre-select the visitor's country based on their IP:
import { headers } from 'next/headers';
async function detectCountry(): Promise {
const ip = headers().get('x-forwarded-for')?.split(',')[0];
if (!ip) return undefined;
const res = await fetch(`https://api.apogeoapi.com/v1/ip/${ip}`, {
headers: { 'X-API-Key': process.env.APOGEOAPI_KEY! },
next: { revalidate: 3600 },
});
if (!res.ok) return undefined;
const { country } = await res.json();
return country.iso2;
}
Then pass value={await detectCountry()} to the component. The dropdown opens with the visitor's country pre-selected — the most-frequent reason for needing this widget at all.
Accessibility checklist
- ✓
aria-haspopupandaria-expandedon trigger - ✓
role="listbox"on the dropdown panel - ✓
role="option"andaria-selectedon each item - ✓ Keyboard: focus moves to search input on open (autoFocus)
- ✗ Arrow-key navigation between items — add if your spec requires it (most don't)
Bundle impact
Component itself: ~2KB minified+gzipped. Flag images load lazily from flagcdn.com via the flagUrl field — no bundling, no build-time conversion, no PNG sprite. ApogeoAPI returns the flag URL inline so you don't need a separate flag CDN config.
Free API key at apogeoapi.com.
Try ApogeoAPI free
1,000 requests/month forever. 14-day full-access trial. No credit card.
Get your free API key