Skip to content

Accessibility

Accessibility Guidelines

Accessibility is not optional - it’s a fundamental requirement. WEC Design System follows WCAG 2.1 Level AA as our baseline standard, ensuring our products are usable by everyone.

RequirementDescriptionWEC Implementation
Color contrast4.5:1 for normal text, 3:1 for large textAll text meets AA ratios
Keyboard accessAll functionality available via keyboardFull keyboard navigation
Focus visibleClear focus indicators on all interactive elements#0050AE (blue) or #FF0025 (red)
Text alternativesAlt text for images, labels for iconsARIA labels on all icons
FormsLabels, error messages, instructionsInline validation with errors
HeadingsLogical heading structure (h1-h6)Semantic HTML hierarchy
LinksDescriptive link textNever “click here”
KeyActionUsage
TabMove focus forwardNavigate through interactive elements
Shift + TabMove focus backwardNavigate backwards
Enter / SpaceActivateButtons, links, checkboxes
EscapeClose/CancelModals, dropdowns, overlays
Arrow KeysNavigateWithin components (menus, tabs, grids)
Home / EndJump to start/endLists, grids, sliders
Page Up / Page DownScroll by pageLong content areas
// Example: Focus trap in modal
function Modal({ isOpen, onClose }) {
const modalRef = useRef();
useEffect(() => {
if (isOpen) {
// Focus first interactive element
const firstFocusable = modalRef.current?.querySelector(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
firstFocusable?.focus();
// Trap focus within modal
const handleTab = (e) => {
const focusable = modalRef.current?.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.key === 'Tab' && e.shiftKey && document.activeElement === first) {
e.preventDefault();
last?.focus();
} else if (e.key === 'Tab' && !e.shiftKey && document.activeElement === last) {
e.preventDefault();
first?.focus();
}
};
document.addEventListener('keydown', handleTab);
return () => document.removeEventListener('keydown', handleTab);
}
}, [isOpen]);
return (
<div ref={modalRef} role="dialog" aria-modal="true">
{/* Modal content */}
</div>
);
}
function Dropdown({ trigger, children }) {
const [isOpen, setIsOpen] = useState(false);
const triggerRef = useRef();
const handleClose = () => {
setIsOpen(false);
triggerRef.current?.focus(); // Return focus to trigger
};
return (
<>
<button
ref={triggerRef}
onClick={() => setIsOpen(true)}
aria-expanded={isOpen}
>
{trigger}
</button>
{isOpen && (
<div onBlur={handleClose}>
{children}
</div>
)}
</>
);
}

WEC Design System uses two focus colors:

ContextColorHexUsage
Primary focusBlue#0050AEMost interactive elements
Secondary focusRed#FF0025Primary buttons, brand elements
/* Primary focus (blue) */
.button:focus-visible,
.input:focus-visible,
.link:focus-visible {
outline: 2px solid #0050AE;
outline-offset: 2px;
}
/* Secondary focus (red for primary actions) */
.button-primary:focus-visible {
outline: 2px solid #FF0025;
outline-offset: 2px;
}
/* Ensure focus indicator is visible on all backgrounds */
.card:focus-visible {
outline: 2px solid #0050AE;
outline-offset: -2px; /* Inside the card */
}

Logical tab order (left-to-right, top-to-bottom):

<!-- Good: Logical tab order -->
<nav>
<a href="/">Home</a>
<a href="/products">Products</a>
<a href="/about">About</a>
</nav>
<main>
<h1>Products</h1>
<button>Buy Now</button>
</main>
<footer>
<a href="/contact">Contact</a>
</footer>

Provide skip links for keyboard users:

<a href="#main-content" class="skip-link">
Skip to main content
</a>
<main id="main-content">
<!-- Main content -->
</main>
.skip-link {
position: absolute;
top: -100%;
left: 50%;
transform: translateX(-50%);
background: #001A41;
color: white;
padding: 12px 24px;
border-radius: 0 0 8px 8px;
text-decoration: none;
font-weight: 500;
z-index: 10000;
transition: top 0.2s;
}
.skip-link:focus {
top: 0;
outline: 2px solid #FF0025;
}
Text SizeMinimum ContrastWEC Colors
Normal text (< 18px)4.5:1Grey 100 on white passes
Large text (18px+)3:1All headings pass
UI components3:1All buttons/badges pass
ForegroundBackgroundContrast RatioStatus
Grey 100 (#4E5764)White (#ffffff)8.2:1✅ AA + AAA
Telkomsel Red (#FF0025)White (#ffffff)4.5:1✅ AA
Dark Blue (#001A41)White (#ffffff)13.5:1✅ AA + AAA
Success (#008E53)Success Light (#EDFCF0)5.1:1✅ AA
Error (#BC1D42)Error Light (#FDDDD4)4.8:1✅ AA
Info (#0050AE)Info Light (#E9F6FF)6.2:1✅ AA + AAA
Warning (#FDA22B)Warning Light (#FEF3D4)2.8:1⚠️ Use darker text
✅ Do❌ Don’t
Use color + icons for statusUse only red for errors
Check contrast ratiosUse light gray text on white
Support dark/light modesHardcode colors without alternatives
Test with color blindness toolsAssume everyone sees color same
Use underlines for linksRely on color alone for links

Use these tools to verify contrast:

<!-- Good: Semantic elements -->
<nav aria-label="Main navigation">
<ul>
<li><a href="/">Home</a></li>
<li><a href="/products">Products</a></li>
</ul>
</nav>
<main>
<article>
<h1>Product Name</h1>
<p>Description...</p>
</article>
</main>
<aside aria-label="Related products">
<!-- Sidebar content -->
</aside>
<footer>
<p>© 2025 WEC Design System</p>
</footer>

Use ARIA to enhance, not replace, semantic HTML:

<!-- Add labels to icon-only buttons -->
<button aria-label="Close dialog">
<svg>...</svg>
</button>
<!-- Expandable sections -->
<button
aria-expanded="false"
aria-controls="faq-content"
>
FAQ
</button>
<div id="faq-content" hidden>
Answer content...
</div>
<!-- Announce dynamic content -->
<div role="status" aria-live="polite">
Form submitted successfully
</div>
<!-- Hide decorative elements -->
<span aria-hidden="true"></span>
<!-- Label form inputs -->
<label for="email">Email address</label>
<input
id="email"
type="email"
aria-describedby="email-hint"
aria-invalid="false"
aria-required="true"
/>
<span id="email-hint">We'll never share your email</span>
<span id="email-error" role="alert" aria-live="assertive">
Please enter a valid email
</span>
RoleWhen to UseExample
buttonNon-button elements acting as buttons<div role="button">
linkNon-link elements acting as links<div role="link">
navigationSite navigation areas<nav role="navigation">
mainMain content area<main role="main">
complementarySidebars<aside role="complementary">
searchSearch functionality<form role="search">
dialogModal dialogs<div role="dialog">
alertError messages<div role="alert">
statusStatus updates<div role="status">
progressbarProgress indicators<div role="progressbar">
<div class="form-group">
<label for="email">
Email address
<span class="required" aria-label="required">*</span>
</label>
<input
id="email"
type="email"
placeholder="name@example.com"
aria-describedby="email-hint email-error"
aria-invalid="false"
required
/>
<span id="email-hint" class="form-hint">
We'll send confirmation to this email
</span>
<span id="email-error" class="form-error" role="alert" hidden>
Please enter a valid email address
</span>
</div>
  • Clear error messages linked to inputs via aria-describedby
  • Inline validation with helpful feedback
  • Don’t rely solely on color for errors
  • Announce errors to screen readers with role="alert" and aria-live="assertive"
function FormInput({ label, error, hint, ...props }) {
const errorId = `${props.id}-error`;
const hintId = `${props.id}-hint`;
return (
<div class="form-group">
<label for={props.id}>{label}</label>
<input
{...props}
aria-invalid={!!error}
aria-describedby={`${error ? errorId : ''} ${hint ? hintId : ''}`.trim()}
/>
{hint && !error && (
<span id={hintId} class="form-hint">{hint}</span>
)}
{error && (
<span id={errorId} class="form-error" role="alert">
{error}
</span>
)}
</div>
);
}
<!-- Always indicate required fields -->
<label for="phone">
Phone number
<span class="required" aria-label="required">*</span>
</label>
<input
id="phone"
type="tel"
aria-required="true"
required
/>

Ensure interactive elements are large enough for touch:

ElementMinimum SizeWEC Standard
Buttons44×44px48×48px recommended
Links44×44pxWrap small text in padding
Form inputs44px height48px recommended
Checkboxes24×24pxWith padding to 44px
Radio buttons24×24pxWith padding to 44px
/* Icon button - make entire area touchable */
.icon-button {
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
padding: 12px; /* Creates space around icon */
}
/* Text link - add padding */
.inline-link {
display: inline-block;
padding: 8px 12px;
margin: -8px -12px; /* Negative margin prevents layout shift */
}
<!-- Descriptive alt text -->
<img src="product.jpg" alt="Red smartphone on white background">
<!-- Decorative images -->
<img src="decoration.svg" alt="" role="presentation">
<!-- Functional images -->
<img src="search-icon.svg" alt="Search">
<!-- Images with text content -->
<img src="sale-banner.jpg" alt="Flash sale: 50% off all items this weekend only">
<!-- Complex images - use longdesc -->
<img src="chart.png" alt="Sales trend chart" longdesc="chart-description.html">
<!-- Video with captions -->
<video controls>
<source src="video.mp4" type="video/mp4">
<track kind="captions" src="captions.vtt" srclang="en" label="English">
</video>
<!-- Audio with transcript -->
<audio controls>
<source src="audio.mp3" type="audio/mpeg">
</audio>
<a href="transcript.html">Read transcript</a>

Before shipping, test with:

  • Unplug mouse
  • Tab through all interactive elements
  • Verify logical tab order
  • Test Enter/Space on buttons
  • Test Escape for closing modals
  • Test arrow keys in menus
  • NVDA (Windows, free)
  • JAWS (Windows)
  • VoiceOver (macOS/iOS)
  • TalkBack (Android)
  • Chrome DevTools Lighthouse accessibility audit
  • Firefox Accessibility Inspector
  • axe DevTools extension
  • 200% browser zoom
  • 400% browser zoom
  • Text-only zoom (up to 200%)
  • Mobile viewport (320px width)
  • High contrast mode (Windows)
  • Color blindness simulator
  • Automated accessibility testing