← ui

flag

ui

a country's flag as a tiny SVG file, the same on every system, beside a name or as a mark

npx shadcn add @ag/flag
Already installed? Preview an update
npx shadcn add @ag/flag --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-10-15
  • Each flag is now its own SVG file, drawn through image, instead of inline art: any client component that drew one shipped every flag's drawing with it (about 500 KB, 108 KB compressed). The item installs the route that serves them, app/flags/[shape]/[file]/route.ts, read from the installed country-flag-icons and prerendered for every flag at build, so the CDN serves /flags/3x2/ES.svg and no function runs. Migration: shadcn add @ag/flag --overwrite brings the route; a site serving /flags already must move one of them.
  • The route reads a flag's file synchronously: on a site with Cache Components an awaited read is I/O that made the route dynamic, a function per flag, where it is now prerendered for every flag as said above. Update the route with the item.
Updated 2026-10-15 · release notes

also called country flag, flag icon

shelf data & status

uses image

used by country-picker

beside a name

a country nobody set is no flag: hasFlag asks first, and the row keeps the room.

  • Hans Then
  • Aiko Tanaka
  • Lena Fischer
  • Kumaraswamy
  • Peyton Spencer

square, 16 px: the size bar-list gives a mark. the label is empty since the row names the country.

  1. Spain4,210
  2. United States3,102
  3. Japan1,880
  4. Brazil940
  5. Finland233

the flag is sized in em: as tall as the capitals beside it, on their baseline. white and black fields keep a hairline edge on either page.

Aa

Aa

Aa

the demo above, verbatim

flag.demo.tsx
import { Sample } from "@/app/samples"
import { BarList } from "@/registry/base-nova/ui/bar-list"
import { countryName, Flag, hasFlag } from "@/registry/base-nova/ui/flag"

// A server component: every flag's art stays on the server, and the page gets SVG.

// #region people
/** People and where they are from, as a sign-up list has them. "XX" is nobody's. */
const PEOPLE = [
  { name: "Hans Then", country: "NL" },
  { name: "Aiko Tanaka", country: "JP" },
  { name: "Lena Fischer", country: "DE" },
  { name: "Kumaraswamy", country: "IN" },
  { name: "Peyton Spencer", country: "XX" },
]
// #endregion

// #region visitors
/** Visitors by country, ranked, a flag as each row's mark. */
const VISITORS = (
  [
    ["ES", 4_210],
    ["US", 3_102],
    ["JP", 1_880],
    ["BR", 940],
    ["FI", 233],
  ] as const
).map(([code, value]) => ({
  label: countryName(code),
  value,
  icon: <Flag code={code} shape="square" label="" />,
}))
// #endregion

// #region sweep
/** Flags whose fields are white or black, and ones with fine detail, at three sizes. */
const SWEEP = ["JP", "FI", "DE", "ES", "BR", "NP", "CH", "GB-SCT"]
// #endregion

export default function FlagDemo() {
  return (
    <div className="space-y-8">
      <Sample
        name="beside-names"
        glance
        title="beside a name"
        description="a country nobody set is no flag: hasFlag asks first, and the row keeps the room."
        with="people"
      >
        <ul className="w-64 space-y-2 text-sm">
          {PEOPLE.map((p) => (
            <li key={p.name} className="flex items-center gap-2.5">
              <span className="inline-flex w-[1.125em]">
                {hasFlag(p.country) && <Flag code={p.country} />}
              </span>
              {p.name}
            </li>
          ))}
        </ul>
      </Sample>
      <Sample
        name="marks"
        title="as bar-list's marks"
        description="square, 16 px: the size bar-list gives a mark. the label is empty since the row names the country."
        with="visitors"
      >
        <BarList
          label="visitors by country"
          items={VISITORS}
          className="w-80"
        />
      </Sample>
      <Sample
        name="sizes"
        title="with the type, at any size"
        description="the flag is sized in em: as tall as the capitals beside it, on their baseline. white and black fields keep a hairline edge on either page."
        with="sweep"
      >
        <div className="space-y-3">
          {["text-xs", "text-base", "text-3xl"].map((size) => (
            <p
              key={size}
              className={`flex flex-wrap items-center gap-3 ${size}`}
            >
              {SWEEP.map((code) => (
                <Flag key={code} code={code} />
              ))}
              <span>Aa</span>
            </p>
          ))}
          <p className="flex flex-wrap items-center gap-3 text-3xl">
            {SWEEP.map((code) => (
              <Flag key={code} code={code} shape="square" />
            ))}
          </p>
        </div>
      </Sample>
    </div>
  )
}

the source, every file an install writes

flag.tsx
// A COUNTRY'S FLAG, drawn: the same picture on every system, which a flag emoji is not
// (Windows has none and draws the two letters, other systems their own art). The art is
// country-flag-icons' (MIT, maintained; each flag simplified to read at icon size, a
// few hundred bytes where the full-detail sets weigh tens of kilobytes for one coat of
// arms), each its own SVG file the site serves (`flag-route`, installed at
// app/flags/[shape]/[file]/route.ts: prerendered, on the CDN), drawn as an image:
// a page loads the few hundred bytes of the flags it shows, and no script. Inline
// art shipped every flag's drawing with any client component that drew one.
//
//   <Flag code="ES" />                  beside a name: 3:2, as tall as the text's capitals
//   <Flag code="JP" shape="square" />   a mark: 1:1, for a bar-list row or a list's column
//
// Its size is the words': height in em, width from the shape, so a flag beside a name
// at any size is in proportion and on its baseline; `className` sets another
// (`size-4` for bar-list's 16 px mark). A hairline drawn over its edge keeps a white
// field (Japan, Finland) from melting into a light page and a dark one (Germany's
// black) into a dark page.
//
// The code is ISO 3166-1 alpha-2 (`ES`), in either case, and the set's subdivisions
// (`GB-SCT`). A code with no flag THROWS: `hasFlag(code)` asks first, where a country
// may be unknown. Its name is the reader's language's (`Intl.DisplayNames`), as the
// image's label and its title.

import { hasFlag as known } from "country-flag-icons"
import { cn } from "@/lib/utils"
import { Image } from "@/registry/base-nova/ui/image"

export type FlagShape = "rect" | "square"

/** Where each shape's files are served (flag-route). */
const FOLDER: Record<FlagShape, string> = {
  rect: "/flags/3x2",
  square: "/flags/1x1",
}

/** A code as the set names it: upper case, a subdivision's hyphen kept. */
const normal = (code: string) => code.trim().toUpperCase()

/** Whether there is a flag for this code (ISO 3166-1 alpha-2, or the set's
 *  subdivisions such as `GB-SCT`). */
export function hasFlag(code: string): boolean {
  return known(normal(code))
}

const REGION = new Intl.DisplayNames(["en"], { type: "region" })

/** The country's name in English, or the code itself where Intl has none (a
 *  subdivision). */
export function countryName(code: string): string {
  const c = normal(code)
  return (c.length === 2 && REGION.of(c)) || c
}

/** Height in em of each shape: a 3:2 flag as tall as the capitals beside it, a
 *  square one a little taller so its area reads the same. */
const SIZE: Record<FlagShape, string> = {
  rect: "h-[0.75em] w-[1.125em]",
  square: "size-[0.9em]",
}

export function Flag({
  code,
  shape = "rect",
  label,
  className,
}: {
  code: string
  shape?: FlagShape
  /** What the flag says to a screen reader and on hover. Default the country's
   *  name; "" when the name is written beside it already. */
  label?: string
  className?: string
}) {
  const c = normal(code)
  if (!known(c)) throw new Error(`flag: no flag for "${code}"`)
  const said = label ?? countryName(c)
  const look = {
    "data-slot": "flag",
    "data-shape": shape,
    className: cn(FRAME, SIZE[shape], className),
  }
  const art = (
    <Image
      src={`${FOLDER[shape]}/${c}.svg`}
      alt=""
      fill
      fit="cover"
      className="block size-full"
    />
  )
  // Named, it is an image with the country's name; unnamed (the name is written
  // beside it), it is decoration and says nothing.
  if (!said)
    return (
      <span aria-hidden {...look}>
        {art}
      </span>
    )
  return (
    <span role="img" aria-label={said} title={said} {...look}>
      {art}
    </span>
  )
}

/** The flag's box, and the hairline over its edge in the text's colour. */
const FRAME =
  "relative inline-block shrink-0 overflow-hidden align-[-0.08em] after:pointer-events-none after:absolute after:inset-0 after:shadow-[inset_0_0_0_1px_color-mix(in_oklab,currentColor_18%,transparent)] after:content-['']"
flag-route.ts
// THE FLAGS, SERVED: every flag country-flag-icons draws, as its own SVG file at
// `/flags/<shape>/<CODE>.svg` (`3x2`, `1x1`), for `Flag` to show as an image. Read
// from the installed package, prerendered for every flag at build, so the CDN
// serves them and no function runs; nothing is copied into the site. Installed at
// app/flags/[shape]/[file]/route.ts.

import { readFileSync } from "node:fs"
import { join } from "node:path"
import { countries } from "country-flag-icons"

const SHAPES = ["3x2", "1x1"] as const

/** The package's folder: a dependency of the site's own, so at its root (a
 *  bundler's module resolution is no file path: Turbopack's is virtual). Read at
 *  build, when every flag is prerendered. */
const root = join(process.cwd(), "node_modules/country-flag-icons")

export function generateStaticParams() {
  return SHAPES.flatMap((shape) =>
    countries.map((code) => ({ shape, file: `${code}.svg` })),
  )
}

export async function GET(
  _request: Request,
  { params }: { params: Promise<{ shape: string; file: string }> },
) {
  const { shape, file } = await params
  const code = file.replace(/\.svg$/, "")
  if (
    !(SHAPES as readonly string[]).includes(shape) ||
    !countries.includes(code)
  )
    return new Response("no such flag", { status: 404 })
  // Read at once, never awaited: under Cache Components an awaited read is I/O
  // that makes the route dynamic (a function per flag), and "use cache" exists
  // only there. A file the size of a flag costs nothing to read in step.
  const svg = readFileSync(join(root, shape, `${code}.svg`), "utf8")
  return new Response(svg, {
    headers: {
      "content-type": "image/svg+xml; charset=utf-8",
      "cache-control": "public, max-age=31536000, immutable",
    },
  })
}