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/flagAlready installed? Preview an update
npx shadcn add @ag/flag --dry-runYour 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 installedcountry-flag-iconsand prerendered for every flag at build, so the CDN serves/flags/3x2/ES.svgand no function runs. Migration:shadcn add @ag/flag --overwritebrings the route; a site serving/flagsalready 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.
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.
- Spain4,210
- United States3,102
- Japan1,880
- Brazil940
- 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
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
// 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-['']"// 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",
},
})
}