Flag
Flag displays one chosen region as a native image. A case-insensitive two-letter country resolves to local flag art; a nonempty src overrides that registry. When alt is omitted, the image receives the registry's stable English country name. An explicitly supplied string always wins, including alt="" for decoration. Missing, invalid, or failed images reveal application-owned fallback content.
That is the behavioral contract. Width, shape, borders, color, and country-picker layout remain ordinary Tailwind and application markup. Country is explicit application data, never inferred ethnicity or nationality.
Installation
Unreleased
Flag is currently in Klean's Flag draft PR. Published klean-ui@0.0.6 does not contain it yet. Use the source below or the feature CLI until a release includes Flag.
The CLI installs the framework-native component and the licensed flags.js asset module beside it. There is no Klean runtime dependency, provider, initializer, or asset-host configuration.
Run one command from a Boring Stack application. Klean detects the framework and conventional destination, then adds the framework-native source and its direct dependencies.
npx klean-ui add flag- No initializer or configuration file
- No framework, alias, or theme questions
- No Klean runtime dependency
For manual installation, copy the component for your framework and download the complete licensed flags.js into the same flag/ directory. Each displayed component imports ./flags.js. Install tailwind-merge if your application does not already have it. Keep the license comment when editing or redistributing the asset module.
Usage
Vue
<script setup>
import Flag from '@/components/ui/flag/Flag.vue'
</script>
<template>
<span class="inline-flex items-center gap-2">
<Flag country="ng" alt="" class="w-5" />
<span>Nigeria</span>
</span>
<Flag country="KE" class="w-8" />
<Flag
country="GH"
alt="Based in Ghana"
class="size-10 aspect-square rounded-full"
/>
</template>
React
import Flag from '@/components/ui/flag/Flag.jsx'
export default function Countries() {
return (
<div className="flex items-center gap-4">
<span className="inline-flex items-center gap-2">
<Flag country="ng" alt="" className="w-5" />
<span>Nigeria</span>
</span>
<Flag country="KE" className="w-8" />
<Flag
country="GH"
alt="Based in Ghana"
className="size-10 aspect-square rounded-full"
/>
</div>
)
}
Svelte
<script>
import Flag from '$lib/components/ui/flag/Flag.svelte'
</script>
<div class="flex items-center gap-4">
<span class="inline-flex items-center gap-2">
<Flag country="ng" alt="" class="w-5" />
<span>Nigeria</span>
</span>
<Flag country="KE" class="w-8" />
<Flag
country="GH"
alt="Based in Ghana"
class="size-10 aspect-square rounded-full"
/>
</div>
API
| Input | Default | Purpose |
|---|---|---|
country | '' | Case-insensitive two-letter code; surrounding whitespace is ignored. Only codes in the fixed local registry resolve. |
src | '' | Custom image source. A nonempty value takes precedence over country. |
alt | country name | Omitted: stable English name for a known code. Any supplied string overrides it; '' is decorative. |
| slot / children | — | Application-owned fallback content, shown only when no image resolves or the image fails. |
class / className | — | Caller Tailwind merged after the neutral 24px-wide, 3:2 baseline. |
| native image/global attributes | — | IDs, titles, loading, decoding, srcset, sizes, ARIA and native image event hooks. Image-only attributes are removed from fallback spans. |
| element reference | — | Vue exposes element; React forwards ref; Svelte exposes getElement(). The reference points to the current image or fallback. |
The installed flags.js also exports countryName(country), the stable English lookup, and countries, a frozen code list, and flagSource(country, src) for local lookup. That asset list is not a country's localized name or a product eligibility policy.
There are no size, circle, variant, tone, or radius props. The surrounding application owns interaction; Flag stays out of the tab order.
Size and circular crops
Use w-5, w-6, w-8, w-10, or w-12 for 20, 24, 32, 40, or 48px widths. The native image keeps a 3:2 box unless caller classes override it. For a circle, give the image a square box and rounded corners:
<Flag country="NG" class="w-5" />
<Flag country="KE" class="w-8" />
<Flag country="GH" class="size-10 aspect-square rounded-full" />Circular cropping can hide details in a flag. Keep a visible country name when the distinction matters. These treatments are class recipes, not separate components or variants.
Accessibility and fallback
A flag next to a country name should remain decorative:
<span class="inline-flex items-center gap-2">
<Flag country="NG" alt="" />
<span>Nigeria</span>
</span>A standalone <Flag country="NG" /> derives alt="Nigeria". Override it with <Flag country="NG" alt="Based in Nigeria" /> when application meaning differs. alt is a string, never a Boolean switch; a bare Vue alt means the empty string. A missing or failed informative image becomes one named role="img" fallback. A decorative fallback is hidden from assistive technology. Deliberate caller role, aria-label, or aria-hidden semantics are respected.
Changing the resolved image source retries after failure. Availability is ephemeral; Flag does not persist an image failure or the chosen country. The product owns the authoritative country record or draft.
<Flag country="ZZ" alt="Country unavailable">?</Flag>
<Flag country="NG" src="/flags/organization.svg" alt="Organization" />A custom src never silently falls back to country: its precedence remains truthful even when that custom image fails.
Country picker composition
Flag supplies an image, not a country-select widget. Use Select for fixed choices or Combobox for searchable choices. Keep eligible codes, localized labels, validation, persistence and network work in the application.
<script setup>
import { ref } from 'vue'
import Flag from './Flag.vue'
import Select from '../select/Select.vue'
const country = ref('NG')
const options = [
{ value: 'NG', label: 'Nigeria' },
{ value: 'KE', label: 'Kenya' },
{ value: 'GH', label: 'Ghana' },
{ value: 'ZA', label: 'South Africa' }
]
</script>
<template>
<div class="grid w-full max-w-sm gap-3">
<label for="flag-example-country" class="font-medium">Country</label>
<Select id="flag-example-country" v-model="country" :options="options">
<template #value="{ option }"
><span class="flex items-center gap-2"
><Flag :country="option.value" alt="" class="w-5" />{{
option.label
}}</span
></template
>
<template #option="{ option }"
><span class="flex items-center gap-2"
><Flag :country="option.value" alt="" class="w-5" />{{
option.label
}}</span
></template
>
</Select>
</div>
</template>
The example deliberately offers four application-selected regions. A real product can derive localized names with Intl.DisplayNames, sort with Intl.Collator, and choose its own eligible list. A selected region must not be used to infer ethnicity, citizenship, or founder nationality.
Stable names and localization
The shared registry includes checked-in English names. There is no render-time Intl.DisplayNames, browser-language lookup or locale prop. Identical code and props therefore produce identical default names during server rendering and hydration, regardless of the host ICU version. Applications own translation: pass their localized name as alt, or alt="" beside an already visible country label.
Missing or unsupported codes derive "". A custom src without a known country cannot identify image content: supply a meaningful explicit alt or an explicit empty string for decoration. Any nonempty custom src disables inferred naming, even with a known country, because that source could depict anything. Only an explicit alt labels a custom image.
English names are a fixed snapshot, seeded from Node 24.14.1 / ICU 78.2 / CLDR 48.0 and maintained in scripts/flag-country-names.json. XA, XC and XO follow the pinned asset package's own names (Abkhazia, Northern Cyprus and South Ossetia), rather than unrelated Unicode pseudo-region names. This names asset coverage, not political status.
Assets, licensing and performance
Flag uses original Klean component behavior. No Flux proprietary implementation is included. The art comes from the npm distribution of country-flag-icons 1.6.20, licensed under MIT. Its complete copyright and permission notice travels inside installed flags.js. The tarball SHA-1 is aa6f36104568993f9cd43e7da283f9b7d0a802cc.
The pinned registry contains 257 entries: 249 ISO 3166-1 codes plus AC, EU, IC, TA, XA, XC, XK, and XO. This describes the asset package's coverage; it is not a Unicode RGI registry or a political-status policy. Unsupported or malformed codes resolve to fallback. Use a custom source for other art.
The complete registry uses SVG data URLs: approximately 246,858 bytes raw / 69,835 bytes gzip. Importing Flag includes that registry eagerly. Built-in flags make no image-host or tracking requests; there is no runtime asset dependency. Lazy-load an infrequently used picker, or trim application-owned assets when only a fixed subset is needed. The full-size registry is not free merely because it makes no network calls.
Allow data: in the application's CSP img-src when using built-in assets. Custom src follows the application's own network, CSP and privacy policy. Keep asset transformations, remote image hosting and product-specific country restrictions outside the primitive.
Complete framework source
Copy the matching component and the shared licensed asset module. The live Vue preview renders this same source.
Vue source
<script setup>
import { computed, ref, useAttrs, watch } from 'vue'
import { twMerge } from 'tailwind-merge'
import { countryName, flagSource } from './flags.js'
defineOptions({ inheritAttrs: false })
const props = defineProps({
country: { type: String, default: '' },
src: { type: String, default: '' },
alt: { type: String, default: undefined }
})
const attrs = useAttrs()
const element = ref()
const failed = ref(false)
const resolvedAlt = computed(() =>
props.alt === undefined
? props.src
? ''
: countryName(props.country)
: props.alt
)
const imageSource = computed(() => flagSource(props.country, props.src))
const BASE_CLASSES =
'inline-flex aspect-3/2 w-6 shrink-0 items-center justify-center overflow-hidden bg-gray-100 object-cover select-none dark:bg-gray-800'
const forwardedAttrs = computed(() => {
const {
class: _class,
onError: _onError,
'data-slot': _dataSlot,
'data-state': _dataState,
...rest
} = attrs
return rest
})
const fallbackAttrs = computed(() => {
const {
loading: _loading,
decoding: _decoding,
crossorigin: _crossorigin,
referrerpolicy: _referrerpolicy,
fetchpriority: _fetchpriority,
sizes: _sizes,
srcset: _srcset,
usemap: _usemap,
ismap: _ismap,
onLoad: _onLoad,
...rest
} = forwardedAttrs.value
return rest
})
const hasCallerFallbackSemantics = computed(() =>
['role', 'aria-label', 'aria-hidden'].some((name) => name in attrs)
)
const fallbackSemantics = computed(() => {
if (hasCallerFallbackSemantics.value) return {}
if (resolvedAlt.value) return { role: 'img', 'aria-label': resolvedAlt.value }
return { 'aria-hidden': 'true' }
})
const finalFallbackAttrs = computed(() => ({
...fallbackAttrs.value,
...fallbackSemantics.value
}))
watch(
() => imageSource.value,
() => {
failed.value = false
},
{ flush: 'sync' }
)
function handleError(event) {
failed.value = true
const listener = attrs.onError
if (Array.isArray(listener)) {
for (const callback of listener) callback(event)
} else if (typeof listener === 'function') {
listener(event)
}
}
defineExpose({ element })
</script>
<template>
<img
v-if="imageSource && !failed"
ref="element"
v-bind="forwardedAttrs"
data-slot="flag"
data-state="image"
:src="imageSource"
:alt="resolvedAlt"
:class="twMerge(BASE_CLASSES, attrs.class)"
@error="handleError"
/>
<span
v-else
ref="element"
v-bind="finalFallbackAttrs"
data-slot="flag"
data-state="fallback"
:class="twMerge(BASE_CLASSES, attrs.class)"
>
<slot />
</span>
</template>
React source
import { forwardRef, useState } from 'react'
import { twMerge } from 'tailwind-merge'
import { countryName, flagSource } from './flags.js'
const BASE_CLASSES =
'inline-flex aspect-3/2 w-6 shrink-0 items-center justify-center overflow-hidden bg-gray-100 object-cover select-none dark:bg-gray-800'
const Flag = forwardRef(function Flag(
{
country = '',
src = '',
alt,
children,
className,
onError,
onLoad,
'data-slot': _dataSlot,
'data-state': _dataState,
...props
},
ref
) {
const resolvedAlt =
alt === undefined ? (src ? '' : countryName(country)) : alt
const imageSource = flagSource(country, src)
const [imageState, setImageState] = useState(() => ({
source: imageSource,
failed: false
}))
let currentImageState = imageState
if (imageState.source !== imageSource) {
currentImageState = { source: imageSource, failed: false }
setImageState(currentImageState)
}
const showImage = Boolean(imageSource) && !currentImageState.failed
const classes = twMerge(BASE_CLASSES, className)
function handleError(event) {
setImageState({ source: imageSource, failed: true })
onError?.(event)
}
function handleLoad(event) {
setImageState({ source: imageSource, failed: false })
onLoad?.(event)
}
if (showImage) {
return (
<img
{...props}
ref={ref}
data-slot="flag"
data-state="image"
src={imageSource}
alt={resolvedAlt}
className={classes}
onError={handleError}
onLoad={handleLoad}
/>
)
}
const {
loading: _loading,
decoding: _decoding,
crossOrigin: _crossOrigin,
referrerPolicy: _referrerPolicy,
fetchPriority: _fetchPriority,
sizes: _sizes,
srcSet: _srcSet,
useMap: _useMap,
isMap: _isMap,
...fallbackProps
} = props
const hasCallerFallbackSemantics =
props.role !== undefined ||
props['aria-label'] !== undefined ||
props['aria-hidden'] !== undefined
const fallbackSemantics = hasCallerFallbackSemantics
? {}
: resolvedAlt
? { role: 'img', 'aria-label': resolvedAlt }
: { 'aria-hidden': true }
return (
<span
{...fallbackProps}
{...fallbackSemantics}
ref={ref}
data-slot="flag"
data-state="fallback"
className={classes}
>
{children}
</span>
)
})
export default Flag
Svelte source
<script>
import { twMerge } from "tailwind-merge";
import { countryName, flagSource } from "./flags.js";
const BASE_CLASSES =
"inline-flex aspect-3/2 w-6 shrink-0 items-center justify-center overflow-hidden bg-gray-100 object-cover select-none dark:bg-gray-800";
const IMAGE_ONLY_ATTRIBUTES = new Set([
"loading",
"decoding",
"crossorigin",
"referrerpolicy",
"fetchpriority",
"sizes",
"srcset",
"usemap",
"ismap",
]);
let {
country = "",
src = "",
alt,
children,
class: className,
onerror,
onload,
"data-slot": _dataSlot,
"data-state": _dataState,
...props
} = $props();
let element = $state();
let failedSource = $state(null);
let resolvedAlt = $derived(
alt === undefined ? (src ? "" : countryName(country)) : alt,
);
let imageSource = $derived(flagSource(country, src));
let showImage = $derived(
Boolean(imageSource) && failedSource !== imageSource,
);
$effect(() => {
imageSource;
failedSource = null;
});
let fallbackProps = $derived.by(() =>
Object.fromEntries(
Object.entries(props).filter(
([name]) => !IMAGE_ONLY_ATTRIBUTES.has(name),
),
),
);
let hasCallerFallbackSemantics = $derived(
props.role !== undefined ||
props["aria-label"] !== undefined ||
props["aria-hidden"] !== undefined,
);
let fallbackRole = $derived(
hasCallerFallbackSemantics ? props.role : resolvedAlt ? "img" : undefined,
);
let fallbackLabel = $derived(
hasCallerFallbackSemantics ? props["aria-label"] : resolvedAlt || undefined,
);
let fallbackHidden = $derived(
hasCallerFallbackSemantics
? props["aria-hidden"]
: resolvedAlt
? undefined
: true,
);
function handleError(event) {
failedSource = imageSource;
onerror?.(event);
}
function handleLoad(event) {
failedSource = null;
onload?.(event);
}
export function getElement() {
return element;
}
</script>
{#if showImage}
<img
{...props}
bind:this={element}
data-slot="flag"
data-state="image"
src={imageSource}
alt={resolvedAlt}
class={twMerge(BASE_CLASSES, className)}
onerror={handleError}
onload={handleLoad}
/>
{:else}
<span
{...fallbackProps}
bind:this={element}
data-slot="flag"
data-state="fallback"
role={fallbackRole}
aria-label={fallbackLabel}
aria-hidden={fallbackHidden}
class={twMerge(BASE_CLASSES, className)}
>
{@render children?.()}
</span>
{/if}
Related components
- Select and Combobox — own country choice and keyboard behavior.
- Avatar — represents a person or application identity; Flag represents a chosen region.
- Button — owns a real action or navigation destination around a flag.
- Table — composes regional data with explicit labels; country inference and business meaning stay in the application.