Language Switcher
PerfLocale provides multiple ways to display a language switcher on your site.
Gutenberg Block
Add the Language Switcher block from the block inserter (search "Language Switcher" or find it under Widgets).
Block Settings
| Setting | Options | Default |
|---|---|---|
| Style | Flags + Names, Flags Only, Names Only, Dropdown | Flags + Names |
| Layout | Horizontal, Vertical | Horizontal |
| Name Format | Native, English, Both - English (Native), Code | Native |
| Trigger Label Format (dropdown only) | Match Name Format, Native, English, Both, Code only — controls the dropdown button's visible label independently of the options' format. Use Code only to get a compact "EN" pill in the header while the dropdown lists full names. Hidden when Display Mode is not Dropdown. | Match Name Format |
| Show flags | On/Off | On |
| Show names | On/Off | On |
| Hide current | On/Off | Off |
| Show untranslated | On/Off | Off |
| Dropdown arrow | Single chevron, Double chevrons, None (dropdown style only). Theme override via perflocale/switcher/arrow_html filter. | Single chevron |
| Font size | 10–24 px | 14 |
| Flag size | 14–40 px | 20 |
| Gap | 0–24 px | 8 |
The block uses server-side rendering, so you see a live preview in the editor.
The block also honors WordPress's standard block supports on the frontend - Align (left / center / right / wide / full), Spacing (padding / margin), Color, Typography, and Additional CSS class - so the same wrapper classes you see in the editor reach the page. Themes' is-layout-constrained layout rules cannot stretch the dropdown trigger to the full content-area width; the wrapper stays sized to the visible button.
Shortcode: [perflocale_switcher]
Place this shortcode in any post, page, or widget area.
Attributes
| Attribute | Values | Default | Description |
|---|---|---|---|
style | flags_names, flags_only, names_only, dropdown | From settings | Display style |
display | inline, simple, dropdown | From settings | Layout mode. dropdown renders a click-to-expand button + listbox panel. |
layout | horizontal, vertical | horizontal | List direction |
name_format | native, english, both, slug | native | How to display language names in the options list |
trigger_format | inherit, native, english, both, slug | inherit | Dropdown trigger button label format. inherit reuses name_format; an explicit value lets the header pill diverge from the options (e.g. slug trigger + native options renders "EN" on the button + "Deutsch / Français" inside the dropdown). Only meaningful when display="dropdown". |
arrow_style | single, double, none | From settings | Dropdown trigger chevron icon. single = one down chevron (classic native-select look); double = stacked up + down chevrons; none = no icon (theme custom icons via the perflocale/switcher/arrow_html filter). Only meaningful when display="dropdown". |
show_flags | 1 / 0 | 1 | Show flag emoji |
show_names | 1 / 0 | 1 | Show language names |
hide_current | 1 / 0 | From settings | Hide the active language |
show_untranslated | 1 / 0 | From settings | Show languages without a translation |
class | CSS class name | (empty) | Custom CSS class |
Examples
[perflocale_switcher]
[perflocale_switcher style="dropdown"]
[perflocale_switcher style="flags_only" hide_current="1"]
[perflocale_switcher style="names_only" name_format="english"]
[perflocale_switcher layout="vertical" show_flags="0"]
[perflocale_switcher style="flags_names" name_format="both" class="my-switcher"]
[perflocale_switcher display="dropdown" arrow_style="double" trigger_format="slug"]Widget
Go to Appearance > Widgets and add the PerfLocale Language Switcher widget. The widget exposes the full set of per-instance options — Display Mode, Style, Layout, Name Format, Trigger Label Format, Dropdown Arrow, Show flags / names, Hide current, Show untranslated, Font / Flag / Gap sizes — matching the Gutenberg block one-for-one. The dropdown-only fields (Trigger Label Format, Dropdown Arrow) default to "Use site default" so existing widget instances keep the global Settings → Switcher value until you explicitly override.
PHP Template Tag
In your theme templates:
<?php perflocale_language_switcher(); ?>
<?php perflocale_language_switcher( [
'template' => 'flags_names',
'display' => 'dropdown',
'show_flags' => true,
'show_names' => true,
'show_native' => true,
'hide_current' => false,
'name_format' => 'native',
'trigger_format' => 'slug', // "EN" on the trigger button
'arrow_style' => 'double', // up + down chevrons
'class' => 'my-custom-switcher',
] ); ?>Per-instance attribute coverage across surfaces
Every rendering surface — block, shortcode, template tag, WP widget, page-builder integrations, theme addons, the floating Customizer switcher — supports the same set of per-instance overrides. Unset / empty values fall through to the global Settings → Switcher defaults, so you can configure the site-wide look once and only override specific instances when needed.
| Surface | Trigger Label Format | Dropdown Arrow |
|---|---|---|
| Gutenberg Block | Inspector → Display | Inspector → Appearance |
Shortcode [perflocale_switcher] | trigger_format="…" | arrow_style="…" |
PHP Template Tag perflocale_language_switcher() | 'trigger_format' => '…' | 'arrow_style' => '…' |
| WP Widget (Appearance → Widgets) | Trigger Label Format dropdown | Dropdown Arrow dropdown |
| Elementor widget | Trigger Label Format select (dropdown-only) | Dropdown Arrow select (dropdown-only) |
| Beaver Builder module | Trigger Label Format select | Dropdown Arrow select |
| Bricks element | Trigger Label Format select (dropdown-only) | Dropdown Arrow select (dropdown-only) |
| Customizer floating switcher | perflocale_floating_trigger_format control | perflocale_floating_arrow_style control |
| Blocksy header + footer language switcher | Trigger Label Format option (dropdown-only) | Dropdown Arrow option (dropdown-only) |
| Kadence header switcher | perflocale_kadence_trigger_format Customizer control | perflocale_kadence_arrow_style Customizer control |
| Neve header builder component | Trigger Label Format component setting | Dropdown Arrow component setting |
The dropdown-only controls (Trigger Label Format + Dropdown Arrow) are hidden in the UI when the surface's Display Mode is set to inline / simple / list — there's no trigger button to format in those modes. If you set them anyway (e.g. via shortcode attribute), the block render silently ignores them outside dropdown mode, so it's safe to script.
Customising the switcher output
The switcher HTML is generated in PHP (src/Frontend/LanguageSwitcher.php and LanguageSwitcherBlock.php) - there are no PHP templates to copy into your theme. Customise it via filters and CSS instead:
// 1. Filter the language list before rendering (e.g. hide a language).
add_filter( 'perflocale/switcher/languages', function( $languages ) {
return array_filter( $languages, fn( $lang ) => $lang->slug !== 'de' );
} );
// 2. Filter the <a> attributes for each link (add data attributes, classes, etc.).
add_filter( 'perflocale/switcher/link_attrs', function( $attrs, $lang, $current_slug, $args ) {
$attrs['data-track'] = 'lang-switch:' . $lang->slug;
return $attrs;
}, 10, 4 );
// 3. Inject HTML at the start / end of the dropdown panel.
// Useful for region groupings, search inputs, branding chrome, etc.
// Returned HTML is sanitised through kses_switcher() — extend the
// allowlist with perflocale/switcher/kses_allowed_html if needed.
add_filter( 'perflocale/switcher/panel_before', function( $html, $languages, $current_slug, $attrs ) {
return '<div role="presentation" class="my-switcher-heading">Choose your language</div>';
}, 10, 4 );
// 4. Swap the dropdown chevron for a custom icon.
add_filter( 'perflocale/switcher/arrow_html', function( $html, $style ) {
return '<i class="fa-solid fa-chevron-down" aria-hidden="true"></i>';
}, 10, 2 );
// 5. Filter the inner HTML of every option (flag + label).
// Same callback fires from dropdown, inline, simple, and list paths.
add_filter( 'perflocale/switcher/option_content', function( $inner, $lang ) {
// Wrap in <bdi> for safe mixed-direction rendering of e.g.
// "العربية" inside an LTR option list.
return '<bdi>' . $inner . '</bdi>';
}, 10, 2 );
// 6. Filter the entire rendered HTML as a last-resort override.
add_filter( 'perflocale/switcher/output', function( $html, $args ) {
// Wrap the switcher in your own container, swap markup, etc.
return '<div class="my-switcher-wrap">' . $html . '</div>';
}, 10, 2 );Built-in CSS class hooks (target these in your theme stylesheet to restyle without touching markup):
.perflocale-switcher-block- root nav element on the block/shortcode switcher.perflocale-switcher-block--dropdown/.perflocale-switcher-block--horizontal/.perflocale-switcher-block--vertical/.perflocale-switcher-block--simple- mode modifier (set from the block’s Display attribute).perflocale-switcher-block__item,.perflocale-switcher-block__item--current,.perflocale-switcher-block__label,.perflocale-switcher-block__flag- per-language item internals (used by horizontal / vertical / simple modes).perflocale-dd__anchor- inline-block wrapper inside the dropdown<nav>that shrink-wraps to the trigger button. The panel anchors here (not the wider<nav>) somin-width: 100%on the panel correctly means "at least as wide as the trigger"..perflocale-dd__trigger,.perflocale-dd__panel,.perflocale-dd__option- dropdown internals.perflocale-dd__flag,.perflocale-dd__label,.perflocale-dd__arrow- dropdown content slots
CSS custom properties
The dropdown surface exposes every colour, spacing, timing, and z-index value as a --perflocale-dd-* custom property with a hardcoded fallback. A theme recolours / resizes the switcher by overriding the relevant vars instead of restating rules; the matching selector specificity is (0,2,0), so any (0,2,0)-or-higher selector wins.
/* Dark-theme palette. */
.my-dark-theme .perflocale-switcher-block--dropdown {
--perflocale-dd-bg: #1f2937;
--perflocale-dd-text: #f3f4f6;
--perflocale-dd-border: #374151;
--perflocale-dd-border-hover: #4b5563;
--perflocale-dd-panel-bg: #111827;
--perflocale-dd-panel-border: #1f2937;
--perflocale-dd-option-text: #d1d5db;
--perflocale-dd-option-hover-bg: #1f2937;
--perflocale-dd-option-hover-text: #f3f4f6;
--perflocale-dd-active-bg: #312e81;
--perflocale-dd-active-text: #e0e7ff;
}
/* Tighter pill + faster open. */
.compact-header .perflocale-switcher-block--dropdown {
--perflocale-dd-trigger-padding-block: 4px;
--perflocale-dd-trigger-padding-inline: 8px;
--perflocale-dd-trigger-radius: 12px;
--perflocale-dd-transition-duration: 0.08s;
}| Group | Variables |
|---|---|
| Surface colours | --perflocale-dd-bg, --perflocale-dd-text, --perflocale-dd-border, --perflocale-dd-border-hover, --perflocale-dd-focus-border, --perflocale-dd-focus-ring, --perflocale-dd-panel-bg, --perflocale-dd-panel-border, --perflocale-dd-panel-shadow |
| Option states | --perflocale-dd-option-text, --perflocale-dd-option-hover-bg, --perflocale-dd-option-hover-text, --perflocale-dd-active-bg, --perflocale-dd-active-text, --perflocale-dd-active-hover-bg |
| Trigger spacing | --perflocale-dd-trigger-padding-block, --perflocale-dd-trigger-padding-inline, --perflocale-dd-trigger-gap, --perflocale-dd-trigger-radius, --perflocale-dd-trigger-border-width, --perflocale-dd-trigger-focus-ring |
| Panel spacing | --perflocale-dd-panel-offset (gap below trigger), --perflocale-dd-panel-radius, --perflocale-dd-panel-border-width, --perflocale-dd-panel-padding-block, --perflocale-dd-panel-padding-inline, --perflocale-dd-panel-z-index |
| Option spacing | --perflocale-dd-option-padding-block, --perflocale-dd-option-padding-inline, --perflocale-dd-option-gap |
| Arrow / timing | --perflocale-dd-arrow-size (em), --perflocale-dd-arrow-opacity, --perflocale-dd-transition-duration, --perflocale-dd-option-transition-duration |
| Per-instance (set inline by the block / shortcode) | --perflocale-font-size, --perflocale-flag-size, --perflocale-gap |
All transitions on .perflocale-dd__trigger, .perflocale-dd__arrow, and .perflocale-dd__option are disabled inside a @media (prefers-reduced-motion: reduce) block, so users who opt out at the OS level don't see the cosmetic fades / arrow rotation.
RTL and logical properties
The dropdown CSS uses logical properties throughout (inset-inline-start instead of left, padding-block / padding-inline instead of padding: 7px 12px, etc.). The panel anchors to the trigger's inline-start edge on LTR pages and inline-end on RTL pages without a separate RTL stylesheet, and the horizontal overflow flip in language-switcher-dropdown.js reads getComputedStyle(panel).direction so it measures viewport space against the right physical edge regardless of writing direction.
Accessibility (lang, dir, ARIA)
Every option link, the dropdown trigger button, the disabled-option span, and the current-language span carry lang="<target-slug>", plus dir="rtl" when the target language's text_direction is RTL. The visible text inside each option is in the TARGET language (Français, العربية), not the page language, so screen readers need lang to pronounce labels with the right phonemes, and the browser needs dir="rtl" to render RTL labels right-to-left even on an LTR page. The perflocale/switcher/link_attrs filter still lets you override these per-link if needed.
The dropdown trigger uses aria-haspopup="listbox", aria-expanded, and aria-controls. The panel uses role="listbox" + aria-labelledby pointing at the trigger. Each option carries role="option" + aria-selected="true" on the current language; untranslated options that can't be activated carry aria-disabled="true" and are skipped by the JS arrow-key navigation. Keyboard: Enter / Space / ArrowDown opens + focuses option 1, ArrowUp opens + focuses the last option, arrows / Home / End move focus inside the panel (wrapping), a single printable character does first-letter type-ahead (mirrors native <select>), and Esc closes the panel and returns focus to the trigger.
Live repositioning
While the panel is open, scroll (capture, passive), resize, and a ResizeObserver on the trigger button re-run the panel's positioning so it doesn't drift off-screen when the page reflows (sticky-header scrolls, viewport rotation, mobile browser chrome show/hide, web-font swap). The vertical anchor flips above if there's not enough room below (accounting for the WP admin bar); the horizontal anchor flips to inline-end if the inline-start anchor would push the panel past the viewport edge. Listeners attach on open and detach on close.
JavaScript events
The dropdown switcher emits three namespaced CustomEvents on its <nav> root so external JavaScript (analytics frameworks, A/B testing tools, custom theme triggers, headless integrations) can observe and influence switcher activity without forking the renderer. All three events bubble, so listeners can attach to the <nav> directly or to any ancestor (including document) and observe every switcher on the page from one place.
| Event | When it fires | Cancelable |
|---|---|---|
perflocale:switcher:open | After the panel opens (after the open class + aria-expanded="true" are applied and positioning has run). | No — the state change has already happened. |
perflocale:switcher:close | After the panel closes — from clicking the trigger again, clicking outside, pressing Escape, OR being auto-closed because another switcher opened. One callback covers every closure path. | No. |
perflocale:switcher:navigate | BEFORE the browser follows a clicked option's URL (also fires on keyboard activation — Enter on a focused option dispatches the same DOM click). | Yes — calling event.preventDefault() keeps the user on the current page. |
Event detail
// `perflocale:switcher:open` and `:close`
event.detail = {
nav: HTMLElement, // the switcher's <nav> root
trigger: HTMLElement, // the <button> trigger
panel: HTMLElement, // the listbox <div>
}
// `perflocale:switcher:navigate`
event.detail = {
nav: HTMLElement, // the switcher's <nav> root
trigger: HTMLElement, // the <button> trigger
option: HTMLElement, // the clicked <a> option
slug: string, // language slug, from hreflang / lang attribute
url: string, // option's href
}Usage examples
// 1. GA4: send a `language_switch` event before the page navigates.
// `sendBeacon` flushes the request even though the page is unloading.
document.addEventListener( 'perflocale:switcher:navigate', function ( e ) {
navigator.sendBeacon(
'https://www.google-analytics.com/g/collect',
new Blob( [ JSON.stringify( {
event: 'language_switch',
from: document.documentElement.lang,
to: e.detail.slug,
source: 'header_pill'
} ) ], { type: 'application/json' } )
);
} );
// 2. Unsaved-changes guard: block language change if the user has
// edited a form. preventDefault() keeps them on the current page.
document.addEventListener( 'perflocale:switcher:navigate', function ( e ) {
if ( window.myAppHasUnsavedChanges ) {
if ( ! confirm( 'You have unsaved changes. Switch language anyway?' ) ) {
e.preventDefault();
}
}
} );
// 3. Open / close hooks on a specific switcher only — useful for
// coordinating with a sticky header or pausing a hero-image
// carousel while the dropdown is showing.
document.querySelector( '#header .perflocale-switcher-block--dropdown' )
.addEventListener( 'perflocale:switcher:open', function ( e ) {
document.body.classList.add( 'lang-switcher-open' );
} );
document.querySelector( '#header .perflocale-switcher-block--dropdown' )
.addEventListener( 'perflocale:switcher:close', function ( e ) {
document.body.classList.remove( 'lang-switcher-open' );
} );
// 4. Hotjar / Mixpanel: count every dropdown OPEN as an engagement
// signal (separate from actual language switches).
document.addEventListener( 'perflocale:switcher:open', function ( e ) {
if ( window.hj ) { hj( 'event', 'language_switcher_opened' ); }
} );Notes on scope and behaviour
- Dropdown mode only. The JS that fires these events is enqueued only when the switcher renders in
dropdowndisplay mode. Ininline/simple/listmodes the switcher is just a list of<a>links — attach a normalclicklistener to those links if you need to observe activations. - Bubbling. All three events bubble. Attach at the
<nav>, atdocument, or anywhere in between. The event target is always the<nav>;detail.navis included for ergonomic destructuring on delegated handlers. - Cancelable navigate. Only
perflocale:switcher:navigateis cancelable.openandclosefire AFTER the state change, so canceling them would leave the DOM inconsistent with the event. - Browsers. The script uses the
CustomEventconstructor; if the page is somehow rendered in a context without it (IE, very old WebViews), the events are silently skipped rather than throwing. Every browser PerfLocale targets supports it. - SDK precursor. If a programmatic SDK is added later, these events remain valid public API — they're the read side of the listen / control split a future SDK would expose. Code written against them today won't break when the SDK lands.
Language object properties (passed into filter callbacks)
| Property | Example | Description |
|---|---|---|
slug | fr | 2-3 letter language code |
locale | fr_FR | Full WP locale |
name | French | English name |
native_name | Français | Native name |
flag | 🇫🇷 | Flag emoji |
text_direction | ltr | Text direction (ltr or rtl) |
All switcher filters
The switcher exposes nine filter hooks. See the Hooks reference for full parameter signatures.
| Hook | Purpose |
|---|---|
perflocale/switcher/languages | Filter the list of languages the switcher renders. Applied on every render (not just first fetch), so context-aware shortcode wrappers can scope the list. |
perflocale/switcher/link_attrs | Filter the HTML attributes for each per-language link / span. Add analytics data-*, override hreflang / lang / dir, inject rel / title, add CSS classes. |
perflocale/switcher/option_content | Filter the inner HTML of each option (flag <span> + label <span> by default). Fires from every render surface — dropdown, inline, simple, list. Use to wrap labels in <bdi>, append region badges, prepend custom icons, etc. |
perflocale/switcher/panel_before | Inject HTML at the START of the dropdown panel, inside the listbox, before the first option. Non-interactive chrome should be wrapped in <div role="presentation"> so the parent listbox doesn't announce it as an option. |
perflocale/switcher/panel_after | Inject HTML at the END of the dropdown panel, inside the listbox, after the last option. Same role guidance as panel_before. |
perflocale/switcher/arrow_html | Override the dropdown trigger's chevron icon. Built-in styles: single (down chevron), double (stacked up/down), none (empty). Returning a non-string falls back to empty. |
perflocale/switcher/sanitize_output | Toggle whether kses_switcher() runs wp_kses over the rendered HTML (default true). Sites that fully trust their filter callbacks can return false to drop the parse cost; the per-attribute esc_* calls always run regardless. |
perflocale/switcher/kses_allowed_html | Extend the wp_kses allowlist used by kses_switcher(). Needed when a custom arrow_html / panel_before / panel_after / option_content / link_attrs emits tags or attributes outside the built-in switcher set. |
perflocale/switcher/output | Last-resort hook on the fully-rendered HTML. Wrap, prepend, or substitute. Runs after sanitisation. |
Every filter return that produces HTML (arrow_html, panel_before, panel_after, option_content) is automatically passed through kses_switcher() at the injection point — including on the Gutenberg block render path, which WordPress's do_blocks() doesn't otherwise sanitize. The renderer's per-attribute esc_url() / esc_attr() / esc_html() calls remain in effect on the built-in HTML it emits.
Two additional hooks drive nav-menu auto-injection:
// Auto-inject the switcher items into a registered theme menu location.
// Return true to enable; pair with /switcher/menu_locations to pick which menu.
add_filter( 'perflocale/switcher/add_to_menu', '__return_true' );
// Restrict the auto-injection to specific theme menu locations.
add_filter( 'perflocale/switcher/menu_locations', function( $locations, $args ) {
return [ 'primary', 'mobile' ];
}, 10, 2 );