Skapa tillgängliga komponenter för skärmläsare
Skapa tillgängliga komponenter för skärmläsare
Föreställ dig en NVDA-användare som öppnar en modal med ett formulär, fyller i det och klickar på "Stäng". Modalen försvinner visuellt — display: none i CSS, allt ser bra ut. Förutom att elementet fortfarande ligger kvar i DOM:en, fokus aldrig flyttades någonstans, och skärmläsaren fortsätter att läsa upp innehållet i fönstret som just "stängdes". Användaren hör innehållet i ett formulär hon precis lämnat och har ingen aning om var hon nu befinner sig på sidan. Det här är inget påhittat exempel — det är ett av de vanligaste misstagen i komponenter byggda "på känn", utan förståelse för hur tillgänglighet faktiskt fungerar. I den här artikeln vill jag gå bortom en checklista med regler och visa mekaniken bakom dem — plus några riktiga komponenter där du kan se exakt var fallgroparna finns.
Så här "ser" en skärmläsare faktiskt en sida
Innan vi fixar komponenter är det bra att veta vad en skärmläsare egentligen arbetar med. Den renderar inte sidan så som en webbläsare gör — den arbetar med tillgänglighetsträdet (accessibility tree), en struktur som webbläsaren bygger parallellt med DOM:en. Varje nod i det trädet har tre kärnegenskaper: en roll (är det en knapp, en länk, en rubrik, ett formulärfält), ett namn (det användaren hör som beskrivning) och ett tillstånd (expanderad, ikryssad, inaktiverad).
Den viktigaste konsekvensen: en skärmläsare ser inte din CSS. display: none och visibility: hidden tar bort ett element från tillgänglighetsträdet — det är ett korrekt sätt att dölja något på. Men att bara flytta ett element utanför skärmen (position: absolute; left: -9999px) eller sätta färgen till transparent ändrar ingenting i tillgänglighetsträdet — elementet finns fortfarande kvar och läses fortfarande upp. Det är exakt vad som gick fel i modal-exemplet ovan.
Den andra grundpelaren är DOM-ordningen. I bläddringsläge läser en skärmläsare upp sidan i den ordning elementen står i trädet — oavsett hur du visuellt har arrangerat dem med flex-direction: row-reverse eller grid-template-areas. Om din layout ser visuellt logisk ut men källordningen är godtycklig, får en blind användare sidan "i slumpmässig ordning".
Semantik som första försvarslinjen
Det billigaste sättet att få tillgänglighet rätt är att använda rätt HTML-element i stället för att återuppfinna dess beteende från grunden. Titta på skillnaden:
jsx
// Dåligt – ser ut som en knapp, men är det inteconst SaveButton = ({ onSave }) => (<div className="btn" onClick={onSave}>Spara ändringar</div>);
Denna div har allvarliga brister som inte är uppenbara vid en snabb titt:
- Den går inte att fokusera – du kan inte nå den med Tab-tangenten.
- Den reagerar inte på tangentbordet – Enter och mellanslag gör ingenting, eftersom Reacts
onClickbara lyssnar på mus-/touch-händelser. - Den saknar
button-roll – en skärmläsare läser upp den som vanlig text, utan någon indikation på att den går att "aktivera". - Den saknar ett inaktiverat tillstånd – du kan inte bara sätta
disabled, du skulle behöva simulera det manuellt.
jsx
// Bra – allt ovanstående får du på köpetconst SaveButton = ({ onSave, isSaving }) => (<button type="button" onClick={onSave} disabled={isSaving}>{isSaving ? "Sparar…" : "Spara ändringar"}</button>);
Samma princip gäller för <nav> i stället för <div className="nav">, en <label> kopplad till ett fält via htmlFor i stället för en placeholder som låtsas vara en label, eller <ul>/<li> för listor i stället för en hög <div>-element. Semantisk HTML är inte "snyggare" — den genererar bokstavligen en annan, rikare struktur i tillgänglighetsträdet.
ARIA: när det hjälper och när det stjälper
WAI-ARIA-specifikationen inleds med en regel som är värd att lära sig utantill ordagrant: "No ARIA is better than Bad ARIA" — ingen ARIA är bättre än dålig ARIA. ARIA-attribut lägger inte till beteende — de överskriver bara vad en skärmläsare rapporterar till användaren. Om du lovar något komponenten faktiskt inte kan leverera är du sämre ute än om du inte lagt till något alls.
jsx
// Dåligt – ARIA lovar en knapp, men inget annat backar upp detconst DeleteIcon = ({ onDelete }) => (<span role="button" aria-label="Ta bort objekt" onClick={onDelete}>🗑</span>);
Den här koden säger till skärmläsaren "det här är en knapp" — men lägger varken till tangentbordsstöd eller tabIndex, så en tangentbordsanvändare kan aldrig nå den. Det är värre än ingen role alls, eftersom det ger intrycket att funktionen finns fastän den fysiskt är oåtkomlig.
jsx
// Bra – använd helt enkelt en inbyggd knappconst DeleteIcon = ({ onDelete }) => (<button type="button" onClick={onDelete} aria-label="Ta bort objekt"><span aria-hidden="true">🗑</span></button>);
Lägg märke till aria-hidden="true" på emojin — utan den försöker vissa skärmläsare läsa upp Unicode-tecknets namn ("papperskorg"), vilket låter absurt direkt efter att aria-label redan annonserats. aria-label ersätter helt det användaren hör som synligt innehåll — om ett element redan har läsbar text är aria-labelledby, som pekar på den texten, oftast det bättre valet, så att du slipper underhålla två oberoende beskrivningar som kan glida isär över tid.
Ett praktiskt exempel: en tillgänglig accordion
En enkel knapp visar inte så mycket. Låt oss titta på en komponent där man verkligen måste hantera tillstånd och ARIA-relationer medvetet — en accordion, mönstret med expanderbara sektioner som du känner igen från FAQ-sidor eller inställningspaneler.
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;
Några val i den här koden är inte slumpmässiga:
aria-expandedrapporterar det aktuella tillståndet – utan den hör en skärmläsaranvändare bara "knapp", utan något sätt att veta om sektionen är öppen.aria-controlslänkar knappen till panelen den styr – vissa skärmläsare annonserar den här relationen, vilket gör navigeringen enklare.hidden(i stället för att bara dölja via CSS) garanterar att stängt innehåll verkligen tas bort från tillgänglighetsträdet och Tab-ordningen, så att fokus inte kan hamna i osynligt innehåll.<h3>:n som omsluter knappen håller rubrikhierarkin intakt – skärmläsaranvändare navigerar väldigt ofta via rubriker och hoppar mellan sektioner med H-tangenten.+/−-ikonen hararia-hidden="true", eftersom tillståndet redan kommuniceras viaaria-expanded– utan detta skulle en skärmläsare annonsera det dubbelt, på ett förvirrande sätt.
Live regions och dynamiska meddelanden
Ännu ett vanligt problem: något ändras på sidan utan omladdning, och skärmläsaren märker det aldrig, eftersom det för den inte finns någon anledning att läsa upp ett fragment igen som användaren just nu inte utforskar. Ett klassiskt exempel är ett valideringsfel som dyker upp dynamiskt efter att ett fält lämnats:
jsx
import { useState } from "react";const EmailField = () => {const [error, setError] = useState("");const handleBlur = (event) => {const value = event.target.value;setError(value.includes("@") ? "" : "Ange en giltig e-postadress.");};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" gör att elementet beter sig som en implicit aria-live="assertive"-region – när innehållet ändras avbryter skärmläsaren det den håller på att läsa upp och annonserar det nya meddelandet omedelbart. Det är lämpligt för fel som kräver akut uppmärksamhet, men överanvänd det inte för mindre brådskande uppdateringar (som "utkast sparat") – där passar aria-live="polite" bättre, eftersom den väntar tills användaren är klar med sin nuvarande åtgärd i stället för att avbryta henne. aria-describedby binder dessutom felmeddelandet till fältet, så att skärmläsaren läser upp det tillsammans med labeln varje gång användaren återvänder till det fältet — inte bara i stunden felet först dök upp.
Fokushantering vid navigering i Next.js
Den här fallgropen är specifik för single-page-appar, inklusive Next.js App Router. Vid en klassisk sidövergång (fullständig omladdning) återställer webbläsaren fokus till <body>, och skärmläsaren annonserar den nya dokumenttiteln — användaren vet att hon har hamnat på en ny sida. Vid klientsidig navigering sker inget av detta automatiskt: fokus stannar kvar på länken som klickades (ofta någonstans i navigeringen, utanför det nya innehållet), och skärmläsaren får ingen signal om att något överhuvudtaget ändrats.
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 är tabIndex={-1} – normalt går rubriker inte att fokusera, men det här värdet låter dig flytta fokus till dem programmatiskt (.focus()) utan att lägga till dem i den naturliga Tab-ordningen. Efter varje sökvägsbyte (pathname) återgår fokus till den nya sidans rubrik, så att skärmläsaren annonserar dess titel — precis som vid en traditionell omladdning. Det är en komponent värd att lägga till i din layout en gång och sedan aldrig behöva tänka på igen.
Så här testar du tillgänglighet på riktigt
Automatiserade verktyg som axe-core eller eslint-plugin-jsx-a11y är värda att slå på från dag ett i ett projekt — de fångar de uppenbara misstagen (saknad alt, dålig kontrast, saknade labels) innan de når produktion. Men var medveten om deras begränsningar: enligt forskning från Deque Systems fångar automatiserade verktyg realistiskt sett ungefär 30–40 % av tillgänglighetsproblemen. Resten kräver manuell kontroll, eftersom det handlar om betydelse och sammanhang som en maskin inte kan bedöma — om läsordningen faktiskt är logisk, om ett felmeddelande faktiskt förklarar vad man ska göra, om en fokusfälla i en modal verkligen inte släpper ut användaren.
En konkret, upprepningsbar process som fångar de flesta verkliga problemen:
- Lägg undan musen och gå igenom hela flödet med enbart tangentbordet – Tab, Shift+Tab, Enter, mellanslag, Escape, piltangenter där det är naturligt (t.ex. i en meny). Om du vid något tillfälle inte vet var fokus är, är det redan en bugg.
- Slå på VoiceOver (macOS: Cmd+F5) eller NVDA (Windows, gratis) och gå igenom samma flöde med slutna ögon. Bara detta avslöjar om ordning, namn och tillstånd verkligen är begripliga när de hörs — inte bara på papper.
- Testa dynamiska meddelanden separat – utlös ett formulärfel, byt route, öppna en modal — och kontrollera om skärmläsaren faktiskt annonserade något, inte bara om elementet har rätt attribut i koden.
Slutsats
Tillgängliga komponenter är ingen checklista med attribut som skruvas på i slutet — de är en konsekvens av hur du modellerar tillstånd och struktur redan från början. Tre saker värda att ta med sig från den här artikeln: För det första ger semantisk HTML dig fokuserbarhet, tangentbordsstöd och en korrekt roll gratis — ARIA bör fylla i det HTML inte kan uttrycka, inte ersätta det i komponenter som lika gärna kunde ha varit ett inbyggt element. För det andra måste ändringar i tillstånd och innehåll aktivt annonseras — aria-live, role="alert" och fokushantering efter navigering är inga extratillägg, de är förutsättningen för att en dynamisk app överhuvudtaget ska vara användbar utan syn. För det tredje ersätter inget automatiserat verktyg det att gå igenom sitt eget gränssnitt med slutna ögon — det är det snabbaste sättet att se exakt var din "tillgängliga" komponent faktiskt tappar användaren.