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

SettingOptionsDefault
StyleFlags + Names, Flags Only, Names Only, DropdownFlags + Names
LayoutHorizontal, VerticalHorizontal
Name FormatNative, English, Both - English (Native), CodeNative
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 flagsOn/OffOn
Show namesOn/OffOn
Hide currentOn/OffOff
Show untranslatedOn/OffOff
Dropdown arrowSingle chevron, Double chevrons, None (dropdown style only). Theme override via perflocale/switcher/arrow_html filter.Single chevron
Font size10–24 px14
Flag size14–40 px20
Gap0–24 px8

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

AttributeValuesDefaultDescription
styleflags_names, flags_only, names_only, dropdownFrom settingsDisplay style
displayinline, simple, dropdownFrom settingsLayout mode. dropdown renders a click-to-expand button + listbox panel.
layouthorizontal, verticalhorizontalList direction
name_formatnative, english, both, slugnativeHow to display language names in the options list
trigger_formatinherit, native, english, both, sluginheritDropdown 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_stylesingle, double, noneFrom settingsDropdown 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_flags1 / 01Show flag emoji
show_names1 / 01Show language names
hide_current1 / 0From settingsHide the active language
show_untranslated1 / 0From settingsShow languages without a translation
classCSS 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.

SurfaceTrigger Label FormatDropdown Arrow
Gutenberg BlockInspector → DisplayInspector → 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 dropdownDropdown Arrow dropdown
Elementor widgetTrigger Label Format select (dropdown-only)Dropdown Arrow select (dropdown-only)
Beaver Builder moduleTrigger Label Format selectDropdown Arrow select
Bricks elementTrigger Label Format select (dropdown-only)Dropdown Arrow select (dropdown-only)
Customizer floating switcherperflocale_floating_trigger_format controlperflocale_floating_arrow_style control
Blocksy header + footer language switcherTrigger Label Format option (dropdown-only)Dropdown Arrow option (dropdown-only)
Kadence header switcherperflocale_kadence_trigger_format Customizer controlperflocale_kadence_arrow_style Customizer control
Neve header builder componentTrigger Label Format component settingDropdown 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>) so min-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;
}
GroupVariables
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.

EventWhen it firesCancelable
perflocale:switcher:openAfter 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:closeAfter 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:navigateBEFORE 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 dropdown display mode. In inline / simple / list modes the switcher is just a list of <a> links — attach a normal click listener to those links if you need to observe activations.
  • Bubbling. All three events bubble. Attach at the <nav>, at document, or anywhere in between. The event target is always the <nav>; detail.nav is included for ergonomic destructuring on delegated handlers.
  • Cancelable navigate. Only perflocale:switcher:navigate is cancelable. open and close fire AFTER the state change, so canceling them would leave the DOM inconsistent with the event.
  • Browsers. The script uses the CustomEvent constructor; 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)

PropertyExampleDescription
slugfr2-3 letter language code
localefr_FRFull WP locale
nameFrenchEnglish name
native_nameFrançaisNative name
flag🇫🇷Flag emoji
text_directionltrText direction (ltr or rtl)

All switcher filters

The switcher exposes nine filter hooks. See the Hooks reference for full parameter signatures.

HookPurpose
perflocale/switcher/languagesFilter 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_attrsFilter 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_contentFilter 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_beforeInject 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_afterInject HTML at the END of the dropdown panel, inside the listbox, after the last option. Same role guidance as panel_before.
perflocale/switcher/arrow_htmlOverride 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_outputToggle 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_htmlExtend 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/outputLast-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 );