TailwindshadcnReact

Country Dropdown for Tailwind / shadcn — Drop-In Component

ApogeoAPI5 min read

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-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.

Try ApogeoAPI free

1,000 requests/month forever. 14-day full-access trial. No credit card.

Get your free API key