npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@lilydesignsystem/vue-locale-picker

v0.2.0

Published

Lily Design System Vue 3 locale picker: an icon button opening an APG listbox that sets lang and dir on the document. Headless, SSR-safe, no CSS.

Readme

LocalePicker (Vue helper)

A reusable, headless Vue 3 locale picker — an icon button that opens a WAI-ARIA APG listbox — that applies the chosen locale to the document root via lang and dir, with optional localStorage persistence and navigator.languages detection.

For the full contract see spec/index.md — it is the single source of truth for the API, behaviour, and tests. For topic deep-dives see docs/ and for working code see examples/.

Table of contents

Install

This directory is published as a folder-style import; consumers either copy it into their project or wire it as a workspace dependency. The only runtime dependency is vue ≥ 3.

import LocalePicker from "./lily-design-system-vue-locale-picker/LocalePicker.vue";

Or via the barrel (recommended; gives you the typed helpers too):

import LocalePicker, {
  bcp47LocaleTag,
  isRtlLocale,
  localeName,
  matchNavigatorLanguage,
  defaultLocaleLabels,
  type Props,
  type SlotArgs,
} from "./lily-design-system-vue-locale-picker";

Quick start

Render the picker with a label and the list of locales your app supports. The control is a globe button; activating it opens a listbox of the locales. The picker writes lang and dir onto <html> so your i18n library, your CSS (html[dir="rtl"]), and assistive technology all see the change.

<script setup lang="ts">
import { ref } from "vue";
import LocalePicker, {
  localeName,
} from "./lily-design-system-vue-locale-picker/LocalePicker.vue";

const locale = ref("");
</script>

<template>
  <LocalePicker
    label="Language"
    :locales="['en', 'en_US', 'fr', 'fr_CA', 'ar', 'he']"
    v-model:value="locale"
    storage-key="lily-locale"
    detect-from-navigator
  />

  <p class="locale-picker-status" aria-live="polite">
    Current language: {{ localeName(locale) }}
  </p>
</template>

The status line is part of the quick start on purpose. The closed control is a bare glyph — it never reads "French" — so the status region is the only channel that reports the active locale, on screen or to a screen reader. It is visible by default (sighted and cognitive-accessibility users benefit too), and aria-live="polite" means it stays silent on first paint and speaks once per change. Opting out is a deliberate decision, not the default; see docs/accessibility.md for the full tradeoff and the visually-hidden variant.

When the user picks ar, the component:

  • sets lang="ar" on <html>,
  • sets dir="rtl" on <html> (auto-detected from the locale),
  • writes "ar" to localStorage["lily-locale"],
  • emits update:value (driving v-model:value),
  • emits change with the new code.

The picker does NOT translate strings — that is the consumer's i18n library (e.g. vue-i18n, Tolgee, Paraglide, raw Intl.*). Wire the bindable value or the change event to your library so it loads the right messages.

The control

The rendered markup is a root <div> holding three things: a hidden input for form participation, an icon button, and a listbox.

<div class="locale-picker">
  <input type="hidden" name="locale" value="en" />
  <button
    type="button"
    class="locale-picker-button"
    aria-label="Language"
    aria-haspopup="listbox"
    aria-expanded="false"
    aria-controls="locale-picker-1-list"
  >
    <svg class="locale-picker-icon" viewBox="0 0 16 16" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="6"/><path stroke-width="1.1" d="M2 8h12M8 2v12M3.35 4.3a8 8 0 0 0 9.3 0M12.65 11.7a8 8 0 0 0-9.3 0M7.54 2.15a7.7 7.7 0 0 0 0 11.7M8.46 13.85a7.7 7.7 0 0 0 0-11.7"/></svg>
  </button>
  <ul
    class="locale-picker-list"
    id="locale-picker-1-list"
    role="listbox"
    aria-label="Language"
    tabindex="-1"
    hidden
  >
    <li
      class="locale-picker-option"
      id="locale-picker-1-option-0"
      role="option"
      aria-selected="true"
      data-active
      lang="en"
    >
      English
    </li>
    <li
      class="locale-picker-option"
      id="locale-picker-1-option-1"
      role="option"
      aria-selected="false"
      lang="cy"
    >
      Welsh
    </li>
  </ul>
</div>

Points worth knowing:

  • The button is icon-only. The icon is a bundled SVG (globe with meridians), not a Unicode character (reversed 2026-09-16), and it is aria-hidden="true" — so label is the button's entire accessible name. The same label also names the listbox.
  • It costs one icon of width no matter how many locales you support, which is why it replaced the native <select>: a select grows to fit the longest option, and the built-in table has 436 of them.
  • An option carries lang only when its label is the derived endonym, so a screen reader pronounces "Cymraeg" with a Welsh voice (WCAG 3.1.2, Language of Parts) and an English fallback label is never handed to the wrong voice. Consumer-labelled options carry no lang; neither do the button and the list.
  • name is the hidden input's name, so the value still posts with a surrounding form.
  • Element ids come from a module counter (nextLocalePickerId()), so they are stable across SSR and hydration.
  • The keyboard contract is the APG listbox pattern, implemented by the component: arrows (clamping, no wrap), Home / End, PageUp / PageDown (by ten, clamping), Enter / Space to commit, Escape to cancel, Tab to close via the button so the default Tab proceeds from the picker's position, and printable-character typeahead over the labels — a repeated character cycles through its matches. See Accessibility.

Because the closed control shows only a glyph, the active locale has no on-screen representation of its own. Surface it yourself — the quick start above shows the .locale-picker-status pattern this package treats as the default.

To style the control, target the class hooks from your own stylesheet (the helper ships zero CSS): .locale-picker (root), .locale-picker-button, .locale-picker-icon, .locale-picker-list, and .locale-picker-option (with [data-active] for the keyboard-active row and [aria-selected="true"] for the committed one).

BCP 47 normalisation

Language tags follow BCP 47 (RFC 5646). The lang attribute on HTML elements must use hyphens, while many applications carry locale identifiers with underscores (en_US, zh_Hant_TW). The picker accepts whichever form you prefer in the locales array and converts to the hyphen form when writing to the DOM. The bindable value preserves your original form, so round-trips are lossless.

bcp47LocaleTag("en_US"); // "en-US"
bcp47LocaleTag("zh_Hant_TW"); // "zh-Hant-TW"
bcp47LocaleTag("en"); // "en"

References:

RTL auto-detection

isRtlLocale(locale) returns true for any locale whose base language is one of ar, arc, ckb, dv, fa, he, iw, ji, ks, ku, mzn, ps, sd, ug, ur, yi, OR whose script subtag is one of Arab, Hebr, Thaa, Syrc, Nkoo, Mong, Adlm.

isRtlLocale("ar"); // true
isRtlLocale("he_IL"); // true
isRtlLocale("uz_Arab_AF"); // true (script subtag)
isRtlLocale("en"); // false

Pass :apply-dir="false" if you want full control of dir yourself.

Examples

Minimal mount

<script setup lang="ts">
import { ref } from "vue";
import LocalePicker from "./lily-design-system-vue-locale-picker/LocalePicker.vue";
const locale = ref("en");
</script>

<template>
  <LocalePicker
    label="Language"
    :locales="['en', 'cy']"
    v-model:value="locale"
  />
</template>

That renders the markup shown under The control: a globe button and a hidden listbox with one <li role="option"> per locale, each carrying its own lang when its label is the endonym.

Pretty labels for the option text

By default each option shows the language's endonym — its own name for itself, "Cymraeg" not "Welsh" — via Intl.DisplayNames asked in that language (exported as localeEndonym). The user who needs a language menu is the one who cannot read the page's language, and the exonym means nothing to them. The English names from locales.tsv and the raw code remain as fallbacks for runtimes without the data. Override per-code with localeLabels:

<LocalePicker
  label="Langue"
  :locales="['en', 'fr', 'ar']"
  :locale-labels="{ en: 'English', fr: 'Français', ar: 'العربية' }"
  v-model:value="locale"
/>

Replacing the button glyph

The default scoped slot replaces the button glyph — not the options. The listbox, the option markup, the keyboard contract, and the apply lifecycle all stay component-owned. The slot receives { value, open, labelFor }:

<LocalePicker
  label="Language"
  :locales="['en', 'fr', 'es', 'de', 'ar']"
  v-model:value="locale"
  storage-key="lily-locale"
>
    <template #default="{ value, open, labelFor }">
        <span
            class="locale-picker-code"
            :title="labelFor(value)"
            aria-hidden="true"
            >{{ value.split(/[-_]/)[0].toUpperCase() }}</span
        >
        <span class="locale-picker-caret" aria-hidden="true">{{
            open ? "▴" : "▾"
        }}</span>
    </template>
</LocalePicker>

Whatever the slot renders is decorative. The button's accessible name always comes from label via aria-label, so mark slot content aria-hidden="true" (or keep it text-free) rather than letting it compete for the name. Showing the active locale's short code is the common case — it gives sighted users the value the bare glyph hides.

If you need a genuinely different control shape — an always-visible button row, a free-text combobox — render it yourself alongside the component and bind both to the same ref. LocalePicker keeps owning the apply lifecycle; your markup is simply a second way to write the bound value. See examples/combobox.vue.

Wiring an i18n library (vue-i18n)

<script setup lang="ts">
import { ref } from "vue";
import { useI18n } from "vue-i18n";
import LocalePicker from "./lily-design-system-vue-locale-picker/LocalePicker.vue";

const { locale } = useI18n();
const current = ref<string>(locale.value);
</script>

<template>
  <LocalePicker
    label="Language"
    :locales="['en', 'fr', 'ar']"
    v-model:value="current"
    detect-from-navigator
    storage-key="app-locale"
    @change="(code) => (locale = code)"
  />
</template>

Server-resolved initial value (SSR)

For flicker-free first paint, resolve the locale on the server (from a cookie or Accept-Language) and pass it as value:

<script setup lang="ts">
import { ref } from "vue";

const props = defineProps<{ initialLocale: string }>();
const locale = ref(props.initialLocale);
</script>

<template>
  <LocalePicker
    label="Language"
    :locales="['en', 'fr', 'ar']"
    v-model:value="locale"
    :value="locale"
  />
</template>

During SSR the component renders the button and the hidden listbox with the supplied value marked aria-selected="true", and the document already arrives with the correct lang attribute on <html>.

Render into a scoped target instead of <html>

Set target to a specific element when you want the locale scoped to a region (e.g. a multilingual side panel):

<script setup lang="ts">
import { ref } from "vue";
import LocalePicker from "./lily-design-system-vue-locale-picker/LocalePicker.vue";

const region = ref<HTMLElement | null>(null);
const panelLocale = ref("fr");
</script>

<template>
  <section ref="region">
    <p>This panel switches language independently of the page.</p>
    <LocalePicker
      label="Panel language"
      :locales="['en', 'fr', 'ar']"
      :target="region"
      v-model:value="panelLocale"
    />
  </section>
</template>

<html> stays in the page's default locale; the section gets the chosen one.

Built-in locale data

locales.ts ships the 436 codes from locales.tsv mapped to their English names. Since the endonym change these are a fallback — the default label is localeEndonym(code), and the table is consulted only when the runtime lacks Intl.DisplayNames data for a code. You can also import the data directly:

import {
  defaultLocaleLabels,
  RTL_LANGUAGE_TAGS,
  RTL_SCRIPT_SUBTAGS,
} from "./lily-design-system-vue-locale-picker";

console.log(defaultLocaleLabels["en_US"]); // "English (United States)"
console.log(RTL_LANGUAGE_TAGS.has("ar")); // true

Props

See spec/index.md §4 for the full table.

Required props: label, locales.

label names both the button and the listbox. The button is icon-only, so this is its only accessible name — pick it carefully; see Accessibility.

Common optional props: value (bindable via v-model:value), defaultValue, storageKey, detectFromNavigator, localeLabels, applyDir, target, class (on the root <div>), name (on the hidden input).

There is no placeholder prop — it was removed along with the <select> it used to pin.

Events

| Event | Payload | When | | -------------- | -------- | ------------------------------------------------------------ | | update:value | string | After selection, drives v-model:value. | | change | string | After the picker applies a new locale (consumer-form code). |

Accessibility

  • The control is a <button aria-haspopup="listbox" aria-expanded aria-controls> paired with a <ul role="listbox"> of <li role="option" aria-selected> — the WAI-ARIA APG listbox pattern.
  • The keyboard contract is implemented by the component: on the button, ArrowDown / Enter / Space open with the selected option active and ArrowUp opens with the last one active; on the listbox, arrows move the active option (clamping, never wrapping), Home / End jump to the ends, PageUp / PageDown move by ten (clamping), Enter / Space commit, Escape cancels, Tab closes after moving focus to the button — without cancelling the key — so the default Tab proceeds from the picker's position, and printable characters run a 500 ms typeahead over the labels where a repeated character cycles through its matches. Focus moves to the <ul> on open and returns to the button on commit or cancel; the active option travels via aria-activedescendant.
  • A locale <li role="option"> carries lang="…" only when its label is the derived endonym, so its name is pronounced in the right language (WCAG 3.1.2, Language of Parts) and an English fallback label is never sent to the wrong speech engine.
  • The document root carries lang and (by default) dir so the page satisfies WCAG 3.1.1 (Language of Page) and bidi text/layout inverts correctly for RTL locales.
  • No colour-only meaning; the active state is in aria-selected, the hidden input, the resolved lang / dir attributes, and the v-model:value binding.

Three tradeoffs come with the icon-button shape, and all three are worth reading before you ship it:

  1. The button has no visible text, so label is its whole accessible name — with a particular irony for a language picker, since label is itself written in one language.
  2. A custom listbox has weaker assistive-technology support than the native <select> it replaced, and there is no OS-native picker on mobile.
  3. The 🌐 glyph renders however the user's fonts render it — or not at all.

The compensating pattern is a visible .locale-picker-status region carrying aria-live="polite", shipped in the quick start above. docs/accessibility.md has the full reasoning for all of it.

SSR

The picker is SSR-safe — all DOM writes happen inside onMounted / watch. For flicker-free first paint, resolve the locale on the server (cookie / Accept-Language) and pass it as value. See docs/ssr.md for the Nuxt 3 recipe.

Files in this directory

| File | Purpose | | ----------------------- | -------------------------------------------------------------------- | | spec/index.md | Single source of truth — API, behaviour, tests. | | AGENTS.md | Fast-index pointer; loads the AGENTS bundle. | | AGENTS/ | Topic-by-topic agent files. | | CLAUDE.md | @AGENTS.md. | | LocalePicker.vue | The component implementation. | | LocalePicker.test.ts | vitest suite covering every spec §7 item. | | locales.ts | Built-in code → English-name map and RTL sets. | | locales.tsv | Canonical 436-row source for locales.ts. | | index.ts | Re-export barrel. | | index.md | This file. | | docs/ | Deep-dive guides — see Documentation. | | examples/ | Runnable Vue 3 SFCs — see Examples directory. | | CHANGELOG.md | Version history. |

Documentation

| Guide | Covers | | ------------------------------------------------------ | ---------------------------------------------------------------------- | | docs/concepts.md | Mental model, lifecycle diagram, why the defaults are what they are. | | docs/bcp47.md | Language-tag syntax (RFC 5646), IANA registry, subtag composition. | | docs/rtl.md | What's auto-detected, what dir="rtl" actually changes, CSS tips. | | docs/i18n-integration.md | Wiring vue-i18n, @intlify, Tolgee, raw Intl.*, Nuxt i18n strategies. | | docs/ssr.md | Cookie, URL-prefix, Accept-Language, FOUC avoidance for Nuxt 3. | | docs/accessibility.md | WCAG 2.2 AAA mapping, keyboard contract, screen-reader matrix. | | docs/props-reference.md | Field-by-field reference for every public prop and event. | | docs/styling.md | Class and attribute hooks, listbox positioning, baseline CSS. | | docs/custom-rendering.md | Replacing the button glyph via the default scoped slot. | | docs/recipes.md | Task-shaped answers: endonyms, cookies, scoped targets, Intl. | | docs/troubleshooting.md | Symptom-first fixes for the common failure modes. |

Examples directory

Each file in examples/ is a complete, runnable Vue 3 SFC you can copy into your project.

| Example | Demonstrates | | ----------------------------------------------------------- | ------------------------------------------------------------------------------- | | basic.vue | The default rendering, plus the .locale-picker-status live region. | | custom-rendering.vue | Custom button glyph — the active locale's short code — via the default slot. | | script-aware-glyph.vue | Script-aware button glyph: the active locale in its own script and direction. | | rtl-demo.vue | Live RTL preview — Arabic, Hebrew, Persian, Urdu, Pashto. | | nhs-style.vue | NHS UK-style utility banner with endonyms and a status line. | | with-vue-i18n.vue | Binding to vue-i18n's locale ref. | | with-paraglide.vue | Driving Paraglide JS's setLocale() from @change. | | ssr-cookie.vue | Nuxt 3 cookie-based SSR — no flash of default locale. | | scoped-target.vue | Multiple per-region pickers, each scoped to its own panel. | | combobox.vue | Built-in typeahead over 436 locales, plus a side-by-side <datalist> combobox. |


Lily™ and Lily Design System™ are trademarks.