Flag
Display a country flag, style it with Tailwind, and add fallback content for missing images.
<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>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>
)
}<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>View source Flag.vue
<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>Installation
Run from your application’s directory. Klean detects your framework and copies the matching source.
npx klean-ui add flagFor manual installation, copy the matching framework source and flags.js into the same flag/ directory, and install tailwind-merge. Keep the asset module's license notice.
Usage
Copy the example above for your framework.
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.
View source CountryPicker.vue
<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.
Stable names and localization
Built-in flags use English country names by default. Pass a localized name as alt, or use alt="" beside a 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.
Assets, licensing and performance
The flag art comes from country-flag-icons, licensed under MIT. Its copyright and permission notice is included in flags.js.
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.
<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>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<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.