Oprettelse af tilgængelige komponenter til skærmlæsere
Forestil dig en NVDA-bruger, der åbner en modal med en formular, udfylder den og klikker på "Luk". Modalen forsvinder visuelt — display: none i CSS, alt ser fint ud. Bortset fra at elementet stadig sidder i DOM'en, fokus aldrig er flyttet nogen steder hen, og skærmlæseren blot fortsætter med at læse indholdet af det vindue, der lige er blevet "lukket" op. Brugeren hører indholdet af en formular, hun lige har forladt, og aner ikke, hvor hun nu befinder sig på siden. Det her er ikke et opdigtet eksempel — det er en af de mest almindelige fejl i komponenter, der er bygget "på skøn", uden forståelse for, hvordan tilgængelighed egentlig fungerer. I denne artikel vil jeg gå ud over en tjekliste med regler og vise mekanismen bag dem — plus et par rigtige komponenter, hvor du kan se præcis, hvor fælderne ligger.
Sådan "ser" en skærmlæser faktisk en side
Før vi retter komponenter, hjælper det at vide, hvad en skærmlæser egentlig arbejder med. Den renderer ikke siden, som en browser gør — den opererer på tilgængelighedstræet (accessibility tree), en struktur, browseren bygger parallelt med DOM'en. Hver node i det træ har tre kerneegenskaber: en rolle (er det en knap, et link, en overskrift, et formularfelt), et navn (det, brugeren hører som beskrivelse) og en tilstand (udvidet, afkrydset, deaktiveret).
Den vigtigste konsekvens: en skærmlæser ser ikke din CSS. display: none og visibility: hidden fjerner et element fra tilgængelighedstræet — det er en korrekt måde at skjule noget på. Men blot at flytte et element uden for skærmen (position: absolute; left: -9999px) eller sætte farven til transparent ændrer ingenting i tilgængelighedstræet — elementet er der stadig og bliver stadig læst op. Det er præcis, hvad der gik galt i modal-eksemplet ovenfor.
Den anden søjle er DOM-rækkefølgen. I browse-tilstand læser en skærmlæser siden op i den rækkefølge, elementerne står i træet — uanset hvordan du visuelt har arrangeret dem med flex-direction: row-reverse eller grid-template-areas. Hvis dit layout giver visuel mening, men kilderækkefølgen er vilkårlig, får en blind bruger siden "i tilfældig rækkefølge".
Semantik som den første forsvarslinje
Den billigste måde at få tilgængelighed rigtig på er at bruge det korrekte HTML-element i stedet for at genopfinde dets adfærd fra bunden. Se på forskellen:
jsx
// Dårligt – ligner en knap, men er det ikkeconst SaveButton = ({ onSave }) => (<div className="btn" onClick={onSave}>Gem ændringer</div>);
Denne div har alvorlige mangler, der ikke er indlysende ved et hurtigt kig:
- Den kan ikke fokuseres – du kan ikke nå den med Tab-tasten.
- Den reagerer ikke på tastaturet – Enter og mellemrum gør ingenting, fordi Reacts
onClickkun lytter til mus-/touch-events. - Den har ingen
button-rolle – en skærmlæser læser den op som almindelig tekst, uden nogen indikation af, at den kan "aktiveres". - Den har ingen deaktiveret tilstand – du kan ikke bare sætte
disabled, du skulle simulere det manuelt.
jsx
// Godt – du får alt det ovenstående gratisconst SaveButton = ({ onSave, isSaving }) => (<button type="button" onClick={onSave} disabled={isSaving}>{isSaving ? "Gemmer…" : "Gem ændringer"}</button>);
Det samme princip gælder for <nav> i stedet for <div className="nav">, en <label> bundet til et felt via htmlFor i stedet for en placeholder, der lader som om den er et label, eller <ul>/<li> til lister i stedet for en stak <div>'er. Semantisk HTML er ikke "pænere" — det genererer bogstaveligt talt en anden, rigere struktur i tilgængelighedstræet.
ARIA: hvornår det hjælper, og hvornår det skader
WAI-ARIA-specifikationen indledes med en regel, det er værd at lære ordret udenad: "No ARIA is better than Bad ARIA" — ingen ARIA er bedre end dårlig ARIA. ARIA-attributter tilføjer ikke adfærd — de overskriver kun det, en skærmlæser rapporterer til brugeren. Hvis du lover noget, komponenten faktisk ikke kan indfri, er du værre stillet, end hvis du ikke havde tilføjet noget som helst.
jsx
// Dårligt – ARIA lover en knap, men intet andet bakker det opconst DeleteIcon = ({ onDelete }) => (<span role="button" aria-label="Slet element" onClick={onDelete}>🗑</span>);
Denne kode fortæller skærmlæseren "det her er en knap" — men tilføjer hverken tastaturunderstøttelse eller tabIndex, så en tastaturbruger kan aldrig nå den. Det er værre end slet ingen role, fordi det giver indtryk af, at funktionen findes, selvom den fysisk er uopnåelig.
jsx
// Godt – brug bare en native knapconst DeleteIcon = ({ onDelete }) => (<button type="button" onClick={onDelete} aria-label="Slet element"><span aria-hidden="true">🗑</span></button>);
Læg mærke til aria-hidden="true" på emojien — uden den forsøger nogle skærmlæsere at læse Unicode-tegnets navn op ("skraldespand"), hvilket lyder absurd lige efter aria-label allerede er blevet annonceret. aria-label erstatter fuldstændigt det, brugeren hører som synligt indhold — hvis et element allerede har læsbar tekst, er aria-labelledby, der peger på den tekst, som regel det bedre valg, så du ikke vedligeholder to uafhængige beskrivelser, der kan glide fra hinanden over tid.
Et praktisk eksempel: en tilgængelig accordion
En simpel knap viser ikke meget. Lad os kigge på en komponent, hvor man for alvor bevidst skal håndtere tilstand og ARIA-relationer — en accordion, mønsteret med udvidelige sektioner, man kender fra FAQ'er eller indstillingspaneler.
jsx
import { useId, useState } from "react";const AccordionItem = ({ title, children, defaultOpen = false }) => {const [isOpen, setIsOpen] = useState(defaultOpen);const contentId = useId();return (<div className="accordion-item"><h3 className="accordion-header"><buttontype="button"className="accordion-trigger"aria-expanded={isOpen}aria-controls={contentId}onClick={() => setIsOpen((open) => !open)}>{title}<span className="accordion-icon" aria-hidden="true">{isOpen ? "−" : "+"}</span></button></h3><div id={contentId} role="region" aria-labelledby={contentId} hidden={!isOpen}>{children}</div></div>);};export default AccordionItem;
Et par valg i denne kode er ikke tilfældige:
aria-expandedrapporterer den aktuelle tilstand – uden den hører en skærmlæserbruger blot "knap", uden nogen måde at vide, om sektionen er åben.aria-controlsknytter knappen til det panel, den styrer – nogle skærmlæsere annoncerer denne relation, hvilket gør navigationen lettere.hidden(frem for kun at skjule via CSS) garanterer, at lukket indhold reelt fjernes fra tilgængelighedstræet og Tab-rækkefølgen, så fokus ikke kan havne i usynligt indhold.<h3>'en, der omslutter knappen, holder overskriftshierarkiet intakt – skærmlæserbrugere navigerer meget ofte via overskrifter og hopper mellem sektioner med H-tasten.+/−-ikonet hararia-hidden="true", fordi tilstanden allerede kommunikeres viaaria-expanded– uden dette ville en skærmlæser annoncere den dobbelt, på en forvirrende måde.
Live regions og dynamiske beskeder
Endnu et almindeligt problem: noget ændrer sig på siden uden en genindlæsning, og skærmlæseren opdager det aldrig, fordi der for den ikke er nogen grund til at genlæse et fragment, brugeren ikke lige nu udforsker. Et klassisk eksempel er en valideringsfejl, der dukker dynamisk op, efter et felt er forladt:
jsx
import { useState } from "react";const EmailField = () => {const [error, setError] = useState("");const handleBlur = (event) => {const value = event.target.value;setError(value.includes("@") ? "" : "Indtast en gyldig e-mailadresse.");};return (<div className="field"><label htmlFor="email">E-mail</label><inputid="email"type="email"aria-invalid={Boolean(error)}aria-describedby={error ? "email-error" : undefined}onBlur={handleBlur}/><span id="email-error" role="alert" className="field-error">{error}</span></div>);};export default EmailField;
role="alert" gør, at elementet opfører sig som en implicit aria-live="assertive"-region – når indholdet ændres, afbryder skærmlæseren det, den er i gang med at læse op, og annoncerer den nye besked med det samme. Det er passende for fejl, der kræver akut opmærksomhed, men overbrug det ikke til mindre presserende opdateringer (som "kladde gemt") – der passer aria-live="polite" bedre, fordi den venter, til brugeren er færdig med sin nuværende handling, i stedet for at afbryde hende. aria-describedby binder desuden fejlmeddelelsen til feltet, så skærmlæseren læser den sammen med labelen, hver gang brugeren vender tilbage til det felt — ikke kun i det øjeblik, fejlen først opstod.
Fokushåndtering ved navigation i Next.js
Denne fælde er specifik for single-page-apps, herunder Next.js App Router. Ved et klassisk sideskift (fuld genindlæsning) nulstiller browseren fokus til <body>, og skærmlæseren annoncerer den nye dokumenttitel — brugeren ved, hun er landet på en ny side. Ved client-side-navigation sker intet af dette automatisk: fokus forbliver på det link, der blev klikket på (ofte et sted i navigationen, uden for det nye indhold), og skærmlæseren får intet signal om, at noget overhovedet er ændret.
jsx
"use client";import { usePathname } from "next/navigation";import { useEffect, useRef } from "react";const RouteAnnouncer = ({ pageTitle }) => {const pathname = usePathname();const headingRef = useRef(null);useEffect(() => {headingRef.current?.focus();}, [pathname]);return (<h1 ref={headingRef} tabIndex={-1} className="visually-focusable-heading">{pageTitle}</h1>);};export default RouteAnnouncer;
Tricket er tabIndex={-1} – normalt kan overskrifter ikke fokuseres, men denne værdi lader dig flytte fokus til dem programmatisk (.focus()) uden at tilføje dem til den naturlige Tab-rækkefølge. Efter hvert stiskifte (pathname) vender fokus tilbage til den nye sides overskrift, så skærmlæseren annoncerer dens titel — præcis som ved en traditionel genindlæsning. Det er en komponent, det er værd at tilføje til dit layout én gang og aldrig tænke over igen.
Sådan tester du tilgængelighed i praksis
Automatiserede værktøjer som axe-core eller eslint-plugin-jsx-a11y er værd at slå til fra dag ét i et projekt — de fanger de indlysende fejl (manglende alt, dårlig kontrast, manglende labels), før de når produktion. Men vær bevidst om deres begrænsninger: ifølge forskning fra Deque Systems fanger automatiserede værktøjer realistisk set omkring 30–40 % af tilgængelighedsproblemerne. Resten kræver manuel kontrol, fordi det handler om mening og kontekst, en maskine ikke kan vurdere — om læserækkefølgen rent faktisk giver mening, om en fejlmeddelelse rent faktisk forklarer, hvad man skal gøre, om en fokusfælde i en modal reelt ikke lader brugeren slippe ud.
En konkret, gentagelig proces, der fanger de fleste virkelige problemer:
- Læg musen væk, og gennemgå hele flowet kun med tastaturet – Tab, Shift+Tab, Enter, mellemrum, Escape, piletaster, hvor det er naturligt (f.eks. i en menu). Hvis du på noget tidspunkt ikke ved, hvor fokus er, er det allerede en fejl.
- Slå VoiceOver til (macOS: Cmd+F5) eller NVDA (Windows, gratis), og gennemgå det samme flow med lukkede øjne. Kun dette afslører, om rækkefølge, navne og tilstande rent faktisk giver mening, når de høres højt — ikke kun på papiret.
- Test dynamiske beskeder separat – udløs en formularfejl, skift rute, åbn en modal — og tjek, om skærmlæseren faktisk annoncerede noget, ikke bare om elementet har det rigtige attribut i koden.
Konklusion
Tilgængelige komponenter er ikke en tjekliste med attributter, der klistres på til sidst — de er en konsekvens af, hvordan du modellerer tilstand og struktur helt fra begyndelsen. Tre ting værd at tage med sig fra denne artikel: For det første giver semantisk HTML dig fokuserbarhed, tastaturunderstøttelse og en korrekt rolle gratis — ARIA bør udfylde det, HTML ikke kan udtrykke, ikke erstatte det i komponenter, der lige så godt kunne have været et nativt element. For det andet skal ændringer i tilstand og indhold aktivt annonceres — aria-live, role="alert" og fokushåndtering efter navigation er ikke ekstraudstyr, de er forudsætningen for, at en dynamisk app overhovedet er brugbar uden syn. For det tredje erstatter intet automatiseret værktøj det at gennemgå sin egen grænseflade med lukkede øjne — det er den hurtigste måde at se præcis, hvor din "tilgængelige" komponent reelt taber brugeren.