Introduction

Running a blog in both Korean and English revealed an interesting problem.

When sharing blog links internationally, visitors often land on the Korean page. Those who can’t read Korean are confused, right? Similarly, when Korean readers click on English post links shared in Korean communities, they face the same issue.

While there’s a language switcher button in the header, new visitors often struggle to find it. This isn’t user-friendly, and from an accessibility perspective, it’s not ideal either.

“How can we kindly guide users to view content in their preferred language?”

This question led me to build a banner that detects browser language and automatically suggests the appropriate language version. Beyond just working, I made sure all users can use it comfortably, designed to meet WCAG 2.2 AA.

This article shares the entire implementation process: from language detection to WCAG 2.2 AA compliance, keyboard navigation, and screen reader support.

Designing the Feature

Core Requirements

1. Smart Language Detection

  • Auto-detect browser language (navigator.language)
  • Korean user + English page → Suggest Korean version
  • Non-Korean user + Korean page → Suggest English version

2. Non-Intrusive UX

  • Small banner that doesn’t obscure much content
  • Fixed at bottom (minimal content interference)
  • Display after 1-second delay (doesn’t interfere with page loading)
  • Smooth slide-up animation

3. Respect User Choice

  • Don’t show again when “Continue” is selected
  • Save choice in localStorage
  • Close anytime with ESC key

4. Accessibility

  • Designed to meet WCAG 2.2 AA
  • Screen reader support
  • All features accessible via keyboard only
  • Touch target minimum 24x24px (implemented as 44x44px)
Language switcher banner design at codeslog
Language switcher banner design at codeslog
Language switching layer at the bottom of the webpage

Technology Stack Selection

Frontend:

  • Vanilla JavaScript (lightweight without frameworks)
  • CSS3 (theme integration with CSS variables)
  • HTML5 (semantic markup)

Hugo Integration:

  • Utilize <link rel="alternate" hreflang> tags
  • Language switching based on Hugo’s translationKey

Accessibility:

  • ARIA attributes (role, aria-label, aria-live)
  • Semantic HTML
  • Keyboard focus management

Browser Language Detection

Using the Navigator API

You can detect the user’s browser language with JavaScript’s navigator.language API.

javascript
function isKoreanUser() {
  const userLang = navigator.language || navigator.userLanguage;
  return userLang.startsWith('ko');
}

function isKoreanPage() {
  const htmlLang = document.documentElement.lang;
  return htmlLang === 'ko' || htmlLang.startsWith('ko-');
}

// When to show the banner
const shouldShowBanner =
  (isKoreanUser() && !isKoreanPage()) ||  // Korean user on English page
  (!isKoreanUser() && isKoreanPage());     // Non-Korean user on Korean page

Key Points:

  • navigator.language returns formats like ko-KR, en-US
  • startsWith('ko') captures all Korean variants (ko, ko-KR, ko-KP, etc.)
  • Works only on client-side, not server-side
Language detection flowchart - Decision flow for browser language check, current page language check, and banner display on mismatch
Language detection flowchart - Decision flow for browser language check, current page language check, and banner display on mismatch
Created with: Nanobanana

Finding Alternative Language URLs

Once you detect the language, you need to know where to guide the user. Finding the correct URL for the alternative language version of each page is crucial.

Hugo automatically generates <link rel="alternate" hreflang> tags for multilingual pages. Using these tags, you can easily find the alternative language URL for the current page.

In my blog, Korean is the default language, so:

  • Korean: https://www.codeslog.com/posts/my-post/ (root path)
  • English: https://www.codeslog.com/en/posts/my-post/ (with language code)

Hugo generates hreflang tags like this:

html
<link rel="alternate" hreflang="ko" href="https://www.codeslog.com/posts/language-switcher-banner/" />
<link rel="alternate" hreflang="en" href="https://www.codeslog.com/en/posts/language-switcher-banner/" />

Read these tags with JavaScript to find alternative language URLs:

javascript
function getAlternateLanguageUrl() {
  const alternateLinks = document.querySelectorAll('link[rel="alternate"][hreflang]');

  for (const link of alternateLinks) {
    const hreflang = link.getAttribute('hreflang');
    const href = link.getAttribute('href');

    // If on Korean page, find English link
    if (isKoreanPage() && hreflang.startsWith('en')) {
      return href;
    }

    // If on English page, find Korean link
    if (!isKoreanPage() && hreflang.startsWith('ko')) {
      return href;
    }
  }

  // If no alternate link exists, try URL pattern conversion
  const currentPath = window.location.pathname;
  return isKoreanPage()
    ? `/en${currentPath}`
    : currentPath.replace(/^\/en\//, '/');
}

Important:

Post files for both languages (index.ko.md, index.en.md) must have identical translationKey values for Hugo to generate hreflang links. If the translationKey values differ, Hugo won’t recognize the pages as translations of each other, and the links will be missing.

yaml
# index.ko.md
translationKey: "my-post"

# index.en.md
translationKey: "my-post"  # Must be identical!

Implementing the Banner UI

HTML Structure Design

Rather than just creating a visually appealing banner, we need to build one that all users can understand and interact with. Using semantic HTML and appropriate ARIA attributes allows screen reader users to understand the banner’s purpose and functionality clearly.

javascript
const banner = document.createElement('div');
banner.id = 'language-switcher-banner';
banner.className = 'language-switcher-banner';
banner.setAttribute('role', 'region');
banner.setAttribute('aria-label', isKoreanPage() ? '언어 제안' : 'Language suggestion');
banner.setAttribute('aria-live', 'polite');
banner.setAttribute('tabindex', '-1');

banner.innerHTML = `
  <div class="banner-content">
    <span class="banner-icon" aria-hidden="true">🌐</span>
    <p class="banner-message">
      Would you like to read this page in Korean?
    </p>
    <div class="banner-actions">
      <a href="${alternateUrl}"
         class="banner-button banner-button-primary"
         lang="ko">
        Switch to Korean
      </a>
      <button type="button"
              class="banner-button banner-button-secondary"
              data-action="dismiss"
              aria-label="Dismiss language suggestion and continue in English">
        Continue in English
      </button>
    </div>
  </div>
`;

Accessibility Points:

  • role="region": Defines the banner area as a landmark
  • aria-label: Informs screen readers of the banner’s purpose
  • aria-live="polite": Announces to screen readers when banner appears (without interruption)
  • lang="ko": Specifies link text language (for correct pronunciation)
  • aria-label on button: Clearly explains button action
  • aria-hidden="true": Hides decorative emoji from screen readers
Banner UI component breakdown - Diagram showing banner elements decomposed into icon, message, primary button, and secondary button
Banner UI component breakdown - Diagram showing banner elements decomposed into icon, message, primary button, and secondary button
Visual breakdown of banner components

CSS Styling

The banner should seamlessly fit the user’s theme preference (light/dark mode). Using CSS variables instead of hardcoded colors ensures the banner automatically adapts to the appropriate colors when the theme changes.

css
.language-switcher-banner {
  position: fixed;
  bottom: 0;
  left: 0;
  right: 0;
  z-index: 1000;
  background: var(--theme);
  border-top: 1px solid var(--border);
  box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.1);
  padding: 1rem;
  transform: translateY(100%);
  transition: transform 0.3s ease-in-out;
}

.language-switcher-banner.is-visible {
  transform: translateY(0);
}

.banner-content {
  max-width: 900px;
  margin: 0 auto;
  display: flex;
  align-items: center;
  gap: 1rem;
  flex-wrap: wrap;
}

/* WCAG 2.5.8: Exceeds minimum touch target size */
.banner-button {
  min-height: 44px;
  min-width: 44px;
  padding: 0.5rem 1rem;
  border-radius: 4px;
  font-size: 0.875rem;
}

/* Keyboard focus indicator */
.banner-button:focus-visible {
  outline: 3px solid var(--primary);
  outline-offset: 2px;
}

Responsive Design:

css
@media (max-width: 768px) {
  .banner-content {
    flex-direction: column;
    align-items: flex-start;
  }

  .banner-icon {
    display: none; /* Hide icon on mobile */
  }

  .banner-actions {
    width: 100%;
  }

  .banner-button {
    flex: 1;
    text-align: center;
  }
}

Achieving Perfect Accessibility

Web accessibility isn’t just about legal compliance. It’s about ensuring that keyboard-only users, screen reader users with visual impairments, people with motor disabilities, and many others can use the web equally. Accessibility must be included from the beginning of design, not added later.

Accessibility checklist - Keyboard, screen reader, visual indicators, touch target size and other accessibility elements
Accessibility checklist - Keyboard, screen reader, visual indicators, touch target size and other accessibility elements
Summary of accessibility checks applied

1. Screen Reader Support

ARIA Live Regions:

javascript
banner.setAttribute('aria-live', 'polite');

When the banner appears, screen readers automatically read the announcement. polite announces after finishing current content without interruption.

Clear Labels:

javascript
banner.setAttribute('aria-label', isKoreanPage() ? '언어 제안' : 'Language suggestion');

Screen reader users immediately understand the banner’s purpose.

Button Descriptions:

html
<button aria-label="Dismiss language suggestion and continue in English">
  Continue in English
</button>

aria-label supplements information that button text alone might not convey.

2. Keyboard Navigation

Initial focus management:

When the banner appears, focus moves to the primary action so users can act immediately. We do not trap focus; normal tab order remains.

javascript
const primaryAction = banner.querySelector('.banner-button-primary');
primaryAction?.focus();

Close with ESC Key:

javascript
document.addEventListener('keydown', (e) => {
  if (e.key === 'Escape') {
    dismissBanner();
  }
});

Keyboard-only users can close the banner anytime.

3. Visual Focus Indicators

Clear Outline:

css
.banner-button:focus-visible {
  outline: 3px solid var(--primary);
  outline-offset: 2px;
}

.language-switcher-banner:focus-within {
  box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.15);
}

Keyboard users can clearly see where focus currently is.

Keyboard navigation example
Keyboard navigation example
Example of tab navigation and focus states

4. Touch Target Size

WCAG 2.2 2.5.8 Target Size (Minimum) requires at least 24x24px. I used 44x44px for better usability.

css
.banner-button {
  min-height: 44px;
  min-width: 44px;
}

Users with motor impairments or mobile users can easily tap.

5. Respecting User Preferences

Reduced Motion:

css
@media (prefers-reduced-motion: reduce) {
  .language-switcher-banner {
    transition: none;
  }
}

Animation is disabled when users with vestibular disorders enable reduced motion settings.

High Contrast Mode:

css
@media (prefers-contrast: high) {
  .banner-button-primary {
    border-width: 2px;
  }
}

High contrast support for users with low vision.

Remembering Choices with localStorage

Once a user has selected “continue in this language,” asking the same question on the next page would be poor UX. Respecting user choices and remembering them so we never ask again is crucial. That’s where localStorage comes in.

javascript
const STORAGE_KEY = 'language-preference-asked';

function dismissBanner() {
  const banner = document.getElementById('language-switcher-banner');
  if (!banner) return;

  banner.classList.remove('is-visible');

  setTimeout(() => {
    banner.remove();
  }, 300);

  // Save to localStorage
  try {
    localStorage.setItem(STORAGE_KEY, 'true');
  } catch (e) {
    console.warn('Failed to save language preference:', e);
  }
}

function init() {
  // Check if already asked
  try {
    if (localStorage.getItem(STORAGE_KEY) === 'true') {
      return; // Don't show banner
    }
  } catch (e) {
    console.warn('Failed to read language preference:', e);
  }

  // ... Banner display logic
}

Error Handling:

In private browsing mode or with certain security settings, localStorage access may be blocked. We handle errors with try-catch so the banner continues to work in these environments. If storage fails, the banner still displays and closes without issues.

localStorage flow diagram - Flowchart showing user choice storage, next visit check, and banner show/hide decision process
localStorage flow diagram - Flowchart showing user choice storage, next visit check, and banner show/hide decision process
Flow for storing choice and suppressing repeat prompts

Performance Optimization

No matter how good the banner’s functionality is, it shouldn’t slow down page loading or harm user experience. I applied several optimizations to make it light and fast while not interfering with critical page rendering.

1. Deferred Loading

html
<script src="{{ "js/language-switcher-banner.js" | relURL }}" defer></script>

Loading scripts with defer doesn’t interfere with page rendering.

2. 1-Second Delay Display

javascript
setTimeout(() => {
  showBanner();
}, 1000);

If the banner appeared immediately after page load, users would be interrupted right when they start reading content. A 1-second delay allows users to settle on the page and recognize the content before the banner appears, making the experience much more natural.

3. Small Bundle Size

  • JavaScript: ~5KB (minified)
  • CSS: ~3KB (minified)
  • Total of 8KB, very lightweight

4. Preventing Duplicate Execution

javascript
if (localStorage.getItem(STORAGE_KEY) === 'true') {
  return; // Don't show banner
}

Skips unnecessary JavaScript execution for users who’ve already made a choice.

Hugo Integration

1. Add CSS

layouts/partials/extend_head.html:

html
<!-- Language Switcher Banner Styles -->
<link rel="stylesheet" href="{{ "css/language-switcher-banner.css" | relURL }}" />

2. Add JavaScript

layouts/partials/extend_footer.html:

html
<!-- Language Switcher Suggestion Banner -->
<script src="{{ "js/language-switcher-banner.js" | relURL }}" defer></script>

3. Verify translationKey

For the banner to work properly, Hugo must be able to connect each language page. Ensure multilingual pages have matching translationKey:

yaml
# index.ko.md
translationKey: "my-post"

# index.en.md
translationKey: "my-post"

If these values differ, Hugo recognizes the pages as separate articles and won’t generate hreflang links. As a result, the banner can’t find the alternative URL.

WCAG 2.2 Compliance Status

The banner is designed to meet WCAG 2.2 AA:

GuidelineLevelStatusImplementation
1.3.1 Info and RelationshipsASemantic HTML, ARIA attributes
1.4.1 Use of ColorAUnderlines, icons, clear text
1.4.3 ContrastAA4.5:1+ color contrast
2.1.1 KeyboardATab, Shift+Tab, ESC support
2.1.2 No Keyboard TrapANo focus trap; ESC to close banner
2.4.3 Focus OrderALogical focus order
2.4.7 Focus VisibleAA3px outline, focus-within
2.5.8 Target Size (Minimum)AAMinimum 24x24px (implemented as 44x44px)
3.2.4 Consistent IdentificationAAConsistent button labels
4.1.2 Name, Role, ValueAARIA, meaningful labels
4.1.3 Status MessagesAAaria-live=“polite”
WCAG 2.2 AA compliance illustration
WCAG 2.2 AA compliance illustration
Badge representing WCAG 2.2 AA alignment

Future Improvements

The current implementation works well, but there are areas for enhancement:

1. Geographic Location Detection

Currently only detects browser language, but Cloudflare Workers’ cf-ipcountry header could be more accurate.

javascript
// Future improvement
const userCountry = request.headers.get('cf-ipcountry');
if (userCountry === 'KR') {
  // Korean IP → Suggest Korean
}

2. More Language Support

My blog currently (as of January 2026) only supports Korean/English, but the implementation can be extended to 3+ languages:

javascript
const supportedLanguages = ['ko', 'en', 'ja', 'zh'];
const userLang = navigator.language.split('-')[0];
const suggestedLang = supportedLanguages.includes(userLang)
  ? userLang
  : 'en'; // Default

3. A/B Testing

I currently have Google Analytics set up and am conducting some tests. I plan to make several attempts to improve usability in the future. You can add Google Analytics events to measure banner effectiveness:

javascript
// Banner shown
gtag('event', 'language_banner_shown', {
  from_lang: currentLang,
  to_lang: suggestedLang
});

// Language switch clicked
gtag('event', 'language_switched', {
  from_lang: currentLang,
  to_lang: suggestedLang
});

If GDPR compliance is required, you can integrate with cookie consent banner:

javascript
if (!hasConsentForStorage()) {
  // Use sessionStorage instead of localStorage
  sessionStorage.setItem(STORAGE_KEY, 'true');
}

Conclusion

For multilingual blogs, language switching UX is crucial. Kindly guiding users to view content in their preferred language, implemented in a way that all users can access, is essential.

Key principles from this implementation:

  1. Non-Intrusive Design - Display only when needed without interfering with content
  2. Respect User Choice - Provide options without forcing
  3. Accessibility First - Design considering all users from the start
  4. Performance Conscious - Lightweight and fast operation

Accessibility is not “nice to have” but essential. Users who only use keyboards, screen reader users, and users with motor impairments should all be able to use it comfortably.

If your multilingual blog or website needs this feature, I hope this article helps!

References