DEV Community

ApogeoAPI
ApogeoAPI

Posted on • Originally published at apogeoapi.com

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 (

 setOpen((o) => !o)}
aria-haspopup="listbox"
aria-expanded={open}
className="flex w-full items-center justify-between rounded-md border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 hover:border-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{selected ? (

{selected.name}

) : (
{placeholder}
)}


{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) => (
 {
onChange(c.iso2);
setOpen(false);
setQuery('');
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-slate-200 hover:bg-slate-800"
>

{c.name}
{c.iso2}

))
)}

)}

);
}
Enter fullscreen mode Exit fullscreen mode

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 (

Country
 console.log(iso2)}
/>

);
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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-haspopup and aria-expanded on trigger
  • role="listbox" on the dropdown panel
  • role="option" and aria-selected on 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.


Originally published at https://apogeoapi.com/blog/tailwind-country-dropdown. Try ApogeoAPI free at apogeoapi.com.

Top comments (0)