tilgjengelighetreactnext.js

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 ikke
const 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 onClick bare 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 gratis
const 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 opp
const 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 knapp
const 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">
<button
type="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-expanded rapporterer gjeldende tilstand – uten den hører en skjermleserbruker bare "knapp", uten noen måte å vite om seksjonen er åpen.
  • aria-controls knytter 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 har aria-hidden="true", fordi tilstanden allerede kommuniseres via aria-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>
<input
id="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.