Oppretting av tilgjengelige komponenter for skjermlesere
Oppretting av tilgjengelige komponenter for skjermlesere
Se for deg en NVDA-bruker som åpner en modal med et skjema, fyller det ut og klikker "Lukk". Modalen forsvinner visuelt — display: none i CSS, alt ser greit ut. Bortsett fra at elementet fortsatt ligger i DOM-en, fokus aldri har flyttet seg noe sted, og skjermleseren fortsetter å lese opp innholdet i vinduet som nettopp ble "lukket". Brukeren hører innholdet i et skjema hun nettopp forlot, og aner ikke hvor hun nå befinner seg på siden. Dette er ikke et oppdiktet eksempel — det er en av de vanligste feilene i komponenter bygget "på øyemål", uten forståelse for hvordan tilgjengelighet faktisk fungerer. I denne artikkelen vil jeg gå utover en sjekkliste med regler og vise mekanismen bak dem — pluss noen ekte komponenter der du kan se nøyaktig hvor fellene ligger.
Slik "ser" en skjermleser en side i praksis
Før vi fikser komponenter, hjelper det å vite hva en skjermleser faktisk jobber med. Den rendrer ikke siden slik en nettleser gjør — den opererer på tilgjengelighetstreet (accessibility tree), en struktur nettleseren bygger parallelt med DOM-en. Hver node i det treet har tre kjerneegenskaper: en rolle (er det en knapp, en lenke, en overskrift, et skjemafelt), et navn (det brukeren hører som beskrivelse) og en tilstand (utvidet, avkrysset, deaktivert).
Den viktigste konsekvensen: en skjermleser ser ikke CSS-en din. display: none og visibility: hidden fjerner et element fra tilgjengelighetstreet — det er en korrekt måte å skjule noe på. Men å bare flytte et element utenfor skjermen (position: absolute; left: -9999px) eller sette fargen til transparent endrer ingenting i tilgjengelighetstreet — elementet er fortsatt der og blir fortsatt lest opp. Det er nøyaktig det som gikk galt i modal-eksempelet over.
Den andre bærebjelken er DOM-rekkefølgen. I nettlesermodus leser en skjermleser siden opp i den rekkefølgen elementene står i treet — uansett hvordan du visuelt har ordnet dem med flex-direction: row-reverse eller grid-template-areas. Hvis layoutet ditt gir visuell mening, men kilderekkefølgen er vilkårlig, får en blind bruker siden "i tilfeldig rekkefølge".
Semantikk som første forsvarslinje
Den billigste måten å få tilgjengelighet riktig på, er å bruke riktig HTML-element i stedet for å gjenoppfinne oppførselen fra bunnen av. Se på forskjellen:
jsx
// Dårlig – ser ut som en knapp, men er det ikkeconst SaveButton = ({ onSave }) => (<div className="btn" onClick={onSave}>Lagre endringer</div>);
Denne div-en har alvorlige mangler som ikke er åpenbare ved første øyekast:
- Den er ikke fokuserbar – du kan ikke nå den med Tab-tasten.
- Den reagerer ikke på tastaturet – Enter og mellomrom gjør ingenting, fordi Reacts
onClickbare lytter til mus-/touch-hendelser. - Den har ingen
button-rolle – en skjermleser leser den opp som ren tekst, uten noen indikasjon på at den kan "aktiveres". - Den har ingen deaktivert tilstand – du kan ikke bare sette
disabled, du må simulere det manuelt.
jsx
// Bra – du får alt dette gratisconst SaveButton = ({ onSave, isSaving }) => (<button type="button" onClick={onSave} disabled={isSaving}>{isSaving ? "Lagrer…" : "Lagre endringer"}</button>);
Samme prinsipp gjelder for <nav> i stedet for <div className="nav">, en <label> knyttet til et felt via htmlFor i stedet for en placeholder som later som den er et label, eller <ul>/<li> for lister i stedet for en stabel med <div>-er. Semantisk HTML er ikke "penere" — det genererer bokstavelig talt en annen, rikere struktur i tilgjengelighetstreet.
ARIA: når det hjelper, og når det skader
WAI-ARIA-spesifikasjonen åpner med en regel det er verdt å lære utenat ord for ord: "No ARIA is better than Bad ARIA" — ingen ARIA er bedre enn dårlig ARIA. ARIA-attributter legger ikke til atferd — de overstyrer bare hva en skjermleser rapporterer til brukeren. Hvis du lover noe komponenten faktisk ikke kan innfri, er du dårligere stilt enn om du ikke hadde lagt til noe i det hele tatt.
jsx
// Dårlig – ARIA lover en knapp, men ingenting annet støtter det oppconst DeleteIcon = ({ onDelete }) => (<span role="button" aria-label="Slett element" onClick={onDelete}>🗑</span>);
Denne koden forteller skjermleseren "dette er en knapp" — men legger verken til tastaturstøtte eller tabIndex, så en tastaturbruker kan aldri nå den. Det er verre enn ingen role i det hele tatt, fordi det gir inntrykk av at funksjonen finnes, selv om den fysisk er utilgjengelig.
jsx
// Bra – bruk bare en native knappconst DeleteIcon = ({ onDelete }) => (<button type="button" onClick={onDelete} aria-label="Slett element"><span aria-hidden="true">🗑</span></button>);
Legg merke til aria-hidden="true" på emojien — uten den prøver enkelte skjermlesere å lese opp Unicode-tegnets navn ("papirkurv"), noe som høres absurd ut rett etter at aria-label allerede er annonsert. aria-label erstatter fullstendig det brukeren hører som synlig innhold — hvis et element allerede har lesbar tekst, er aria-labelledby som peker til den teksten som regel det bedre valget, slik at du ikke vedlikeholder to uavhengige beskrivelser som kan gli fra hverandre over tid.
Et praktisk eksempel: en tilgjengelig accordion
En enkel knapp viser ikke mye. La oss se på en komponent der man virkelig må håndtere tilstand og ARIA-relasjoner bevisst — en accordion, mønsteret med utvidbare seksjoner du kjenner fra FAQ-er eller innstillingspaneler.
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;
Noen valg i denne koden er ikke tilfeldige:
aria-expandedrapporterer gjeldende tilstand – uten den hører en skjermleserbruker bare "knapp", uten noen måte å vite om seksjonen er åpen.aria-controlsknytter knappen til panelet den styrer – enkelte skjermlesere annonserer denne relasjonen, noe som gjør navigasjon enklere.hidden(fremfor kun å skjule via CSS) garanterer at lukket innhold faktisk fjernes fra tilgjengelighetstreet og Tab-rekkefølgen, slik at fokus ikke kan havne i usynlig innhold.<h3>-en som omslutter knappen, holder overskriftshierarkiet intakt – skjermleserbrukere navigerer svært ofte via overskrifter og hopper mellom seksjoner med H-tasten.+/−-ikonet hararia-hidden="true", fordi tilstanden allerede kommuniseres viaaria-expanded– uten dette ville en skjermleser annonsert den dobbelt, på en forvirrende måte.
Live regions og dynamiske meldinger
Nok et vanlig problem: noe endrer seg på siden uten omlasting, og skjermleseren merker det aldri, fordi det for den ikke er noen grunn til å lese opp igjen et fragment brukeren ikke utforsker akkurat da. Et klassisk eksempel er en valideringsfeil som dukker dynamisk opp etter at et felt er forlatt:
jsx
import { useState } from "react";const EmailField = () => {const [error, setError] = useState("");const handleBlur = (event) => {const value = event.target.value;setError(value.includes("@") ? "" : "Skriv inn en gyldig e-postadresse.");};return (<div className="field"><label htmlFor="email">E-post</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" gjør at elementet oppfører seg som en implisitt aria-live="assertive"-region – når innholdet endres, avbryter skjermleseren det den holder på å lese opp, og annonserer den nye meldingen umiddelbart. Det passer for feil som krever akutt oppmerksomhet, men ikke overbruk det for mindre presserende oppdateringer (som "utkast lagret") – der passer aria-live="polite" bedre, fordi den venter til brukeren er ferdig med den pågående handlingen sin i stedet for å avbryte henne. aria-describedby knytter i tillegg feilmeldingen til feltet, slik at skjermleseren leser den sammen med labelen hver gang brukeren kommer tilbake til det feltet — ikke bare i det øyeblikket feilen først dukket opp.
Fokushåndtering ved navigasjon i Next.js
Denne fellen er spesifikk for single-page-apper, inkludert Next.js App Router. Ved et klassisk sidebytte (full omlasting) nullstiller nettleseren fokus til <body>, og skjermleseren annonserer den nye dokumenttittelen — brukeren vet at hun har landet på en ny side. Med client-side-navigasjon skjer ingenting av dette automatisk: fokus blir værende på lenken som ble klikket (ofte et sted i navigasjonen, utenfor det nye innholdet), og skjermleseren får ikke noe signal om at noe i det hele tatt har endret seg.
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;
Trikset er tabIndex={-1} – normalt er ikke overskrifter fokuserbare, men denne verdien lar deg flytte fokus til dem programmatisk (.focus()) uten å legge dem til i den naturlige Tab-rekkefølgen. Etter hvert stibytte (pathname) går fokus tilbake til overskriften på den nye siden, slik at skjermleseren annonserer tittelen — akkurat som ved en tradisjonell omlasting. Det er en komponent det er verdt å legge til layoutet én gang og aldri tenke på igjen.
Slik tester du tilgjengelighet i praksis
Automatiserte verktøy som axe-core eller eslint-plugin-jsx-a11y er verdt å skru på fra dag én i et prosjekt — de fanger opp de åpenbare feilene (manglende alt, dårlig kontrast, manglende labels) før de når produksjon. Men vær bevisst på begrensningene deres: ifølge forskning fra Deque Systems fanger automatiserte verktøy realistisk sett omkring 30–40 % av tilgjengelighetsproblemene. Resten krever manuell sjekk, fordi det handler om mening og kontekst en maskin ikke kan vurdere — om leserekkefølgen faktisk gir mening, om en feilmelding faktisk forklarer hva man skal gjøre, om en fokusfelle i en modal virkelig ikke lar brukeren slippe unna.
En konkret, gjentagbar prosess som fanger opp de fleste reelle problemene:
- Legg musen til side, og gå gjennom hele flyten kun med tastaturet – Tab, Shift+Tab, Enter, mellomrom, Escape, piltaster der det er naturlig (f.eks. i en meny). Hvis du på noe tidspunkt ikke vet hvor fokus er, er det allerede en bug.
- Skru på VoiceOver (macOS: Cmd+F5) eller NVDA (Windows, gratis), og gå gjennom samme flyt med lukkede øyne. Bare dette avslører om rekkefølge, navn og tilstander faktisk gir mening når de høres høyt — ikke bare på papiret.
- Test dynamiske meldinger separat – utløs en skjemafeil, bytt rute, åpne en modal — og sjekk om skjermleseren faktisk annonserte noe, ikke bare om elementet har riktig attributt i koden.
Konklusjon
Tilgjengelige komponenter er ikke en sjekkliste med attributter du fester på til slutt — de er en konsekvens av hvordan du modellerer tilstand og struktur helt fra starten. Tre ting verdt å ta med seg fra denne artikkelen: For det første gir semantisk HTML deg fokuserbarhet, tastaturstøtte og riktig rolle gratis — ARIA bør fylle inn det HTML ikke kan uttrykke, ikke erstatte det i komponenter som like gjerne kunne vært et nativt element. For det andre må endringer i tilstand og innhold aktivt annonseres — aria-live, role="alert" og fokushåndtering etter navigasjon er ikke ekstrautstyr, de er forutsetningen for at en dynamisk app i det hele tatt er brukbar uten syn. For det tredje erstatter ingen automatisert verktøy det å gå gjennom sitt eget grensesnitt med lukkede øyne — det er den raskeste måten å se nøyaktig hvor den "tilgjengelige" komponenten din faktisk mister brukeren.