Skip to content

Flag ​

Display a country flag, style it with Tailwind, and add fallback content for missing images.

Preview Live · Vue
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>
jsx
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>
View source Flag.vue
Flag.vue
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.

Terminal
npx klean-ui add flag

Source you own. Edit the installed files to make them yours.

For 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 ​

InputDefaultPurpose
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.
altcountry nameOmitted: 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:

vue
<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:

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

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

Preview Live · Vue
View source CountryPicker.vue
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.

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>
jsx
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
<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}
  • 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.

All open source projects are released under the MIT License.