Skip to main content

Check Performance Data - Accessibility

This is a public sector service, so WCAG 2.2 AA and the Public Sector Bodies (Websites and Mobile Applications) (No. 2) Accessibility Regulations 2018 apply.

Everything below is a defect that was found and fixed — first on the accessibility-pass branch ahead of the Zoonou audit, then from the audit's own findings (epic #384, which names the ticket behind each rule). Treat each as an invariant when adding or editing a view — they are cheap to keep and expensive to re-find.


Page titles

Every page sets ViewData["Title"] / ViewBag.Title, and it matches the <h1>.

The title is the first thing a screen reader announces after a navigation, and it is what appears in browser history and tab lists. GDS convention is that it says the same thing as the page heading, in sentence case.

  • Where the <h1> is an EditableTitle content block, the title matches that block's default text, with a comment in the view saying so (the two can drift once an editor changes the block; the default is the best anchor available).
  • Where the <h1> is dynamic — the school name on the landing page, a date-bearing sentence on Confirm — the title is built from the same value.

Do not prefix a title with "Error: " in a view. _Layout.cshtml and _AdminLayout.cshtml apply PageTitle.WithErrorPrefix (Web/Common/PageTitle.cs) centrally, so no page can forget it. Any new layout must do the same.

The layout triggers the prefix on !ModelState.IsValid. When a page's errors live only on its view model rather than in ModelState — the Summary page's duplicate-request conflict banner, for example — set ViewData["HasError"] = true in the view. WithErrorPrefix is idempotent, so a view that has genuinely already prefixed its own title won't double up.

The GOV.UK Frontend library must not also apply the prefix. GovUk.Frontend.AspNetCore ships a TitleTagHelper targeting <title> inside <head>; with GovUkFrontendOptions.PrependErrorToTitle (its default is true) it appends "Error: " whenever a <govuk-error-summary> has been rendered on the page. Combined with the layout, every error page that used the library's error summary component announced "Error: Error: …". AddCpdGovUkFrontend (Web/Startup/GovUkFrontendExtensions.cs) turns the option off, so the layouts are the single owner — register GOV.UK Frontend through that extension, never AddGovUkFrontend() directly. The layouts keep ownership rather than the tag helper because the tag helper only fires for the library's own error summary, while WithErrorPrefix also covers ViewData["HasError"] pages. GovUkFrontendExtensionsTests guards the option.

Headings

Exactly one <h1> per page.

In particular, do not set is-page-heading="true" on a govuk-radios-fieldset-legend when the page already has an <h1> (typically an EditableTitle). Use class="govuk-fieldset__legend--l" instead — same visual size, no second page heading. Views/CheckYourPupilData/Index.cshtml is the worked example.

Labels

Every form control has a real <label for="…">. aria-describedby is not an accessible name, and neither is a nearby <h1>.

Two patterns, depending on the page:

  • The control is the page's single question — wrap the label in the heading rather than adding a separate <h1>:

    <h1 class="govuk-label-wrapper">
        <label class="govuk-label govuk-label--l" for="pupil-search">@Model.Title</label>
    </h1>
    

    See Views/Journey/PupilSearch.cshtml. This matters doubly for JS-built inputs: accessible-autocomplete creates the input at runtime, so nothing in the server markup carries an accessible name unless a label points at the id the script is told to use.

  • Other content sits between the heading and the control — give the control its own plain label. On _FileUpload.cshtml the uploaded-files table sits between the question heading and the file input, so a for reaching back over the table would read out of order; the input gets <label class="govuk-label" for="fileUpload">Upload a file</label>.

aria-describedby

Only name ids that are actually rendered. A dangling reference resolves to nothing, and browsers can drop the entire description rather than the missing part — so one absent id silently costs you the rest of the description as well.

Build the id list conditionally from what the view emits. QuestionPartialModel.DescribedBy always includes fileUpload-count (the "N of 6 files added." line, rendered in the same branch as the input) and includes fileUpload-error only when there is an error; _FileUpload.cshtml renders exactly those ids. The two must be changed together — QuestionPartialModelDescribedByTests guards the model side and the #389 fact in AccessibilityAuditViewTests the view side.

The file input's description does not repeat the visible hint (#389). In our reading of the audit (JAWS, Chrome 151; Zoonou has not said what was announced): Chrome exposes a native <input type="file"> to assistive technology as a button, and JAWS, rather than reading a button's aria-describedby aloud, tells the user to press JAWS key + Alt + R to hear "descriptive text" (this depends on JAWS version and verbosity settings). When the hint was named, what they would then have heard was "Evidence must be in a PDF format" — the sentence already on screen under the question heading. The auditor reported that detour as redundant hidden instructions (WCAG 4.1.2, Zoonou issue 8). So the hint paragraph stays visible and carries no id, and the input's description names only the count line and the error. Since #380 the control the user reaches is the enhanced component's button, which carries a copy of this description taken at init — the rule and its three pins are unchanged.

This departs, for this one control, from the GOV.UK file upload component, whose hint is the input's description. The trade: a user who tabs straight to the control hears the count and any error, not the format rule. The picker's accept=".pdf,application/pdf" filter and the server's "Evidence must be in a PDF format." error (JourneyController, a non-PDF upload) cover that. If a later audit asks for the hint back, reverse this section, DescribedBy, _FileUpload.cshtml and the three pins together — QuestionPartialModelDescribedByTests, the #389 fact in AccessibilityAuditViewTests, and the negative assertion in E2E EvidenceUploadFileLimitTests — never one side alone.

Two things this does not do, so a retest is read correctly. JAWS will still announce that descriptive text exists, because the count line is always named — pressing the key now leads to "N of 6 files added." and any error, both of which are also on screen. And screen readers that read descriptions automatically (NVDA, Narrator, VoiceOver) no longer announce the PDF rule on the control. If Zoonou's objection is the prompt itself rather than the hint it led to, this change does not resolve it: the count line is the next candidate, or the format could move into the input's label ("Upload a PDF file"), which every screen reader reads with no detour — a content decision, not made here.

accessible-autocomplete

The component owns its input's aria-describedby. It points that attribute at its own generated #{id}__assistiveHint element and rewrites it on every re-render, so anything you set — via inputAttributes, or by poking the DOM after init — is silently discarded.

Pass hint and error text through tAssistiveHint instead, which sets the text of the element aria-describedby already references:

var assistiveHint = (hasError ? 'Error: ' + errorText + '. ' : '')
    + hintText + '. '
    + 'When autocomplete results are available use up and down arrows to review and '
    + 'enter to select. Touch device users, explore by touch or with swipe gestures.';

Error first, then hint, then the component's own arrow-key/touch instructions — which must be kept, since replacing tAssistiveHint replaces them. Both Views/Journey/PupilSearch.cshtml and Views/Journey/_Autocomplete.cshtml do this; keep them in step.

No select in the service is enhanced with enhanceSelectElement any more. The revised-grade, syllabus-code and result pickers were all type-aheads at first; every list turned out short enough to read, so the enhancement was removed and the three are plain GOV.UK <select>s (see docs/results-enquiry.md). That retired a class of defect with it — the input opening with the placeholder row's text as its value (AB#301933), and an onConfirm override having to re-do the library's job of marking the posted <option> selected (AB#301934). If a picker ever needs a type-ahead again, read those tickets before reaching for enhanceSelectElement.

The remaining autocompletes — PupilSearch.cshtml and _Autocomplete.cshtml — are built with accessibleAutocomplete({...}) over a suggestions endpoint, not by enhancing a select. PupilSearch.cshtml is therefore still JavaScript-dependent; that is a known gap, recorded in docs/results-enquiry.md.

A chosen option's details are revealed by the <select>'s own change event (ResultSearch.cshtml, AB#301934). The blocks are server-rendered, one per result, and the server decides which is hidden from the session — so the reveal works with JavaScript off too, and the script is an enhancement rather than the only path. They sit in a polite live region (aria-live="polite") so the change is announced; keep it polite.

A Locator.PressAsync call refocuses its element before dispatching the key. ArrowDown moves DOM focus onto the highlighted <li role="option">, so refocusing the input between an ArrowDown and a following Enter fires the library's handleInputFocus, which resets selected to -1 (the menu stays open; only the highlight is lost). handleEnter is a no-op unless state.selected >= 0, so that Enter silently does nothing. Drive the keyboard path of an autocomplete with Page.Keyboard instead, which sends keys to whatever already has focus.

Every <a class="govuk-button …"> needs role="button" draggable="false" data-module="govuk-button".

Without it, Space does not activate the control — a link responds to Enter only — so keyboard users cannot operate something that looks and reads like a button. The data-module attribute is what wires up GOV.UK Frontend's Space-key handling.

Pagination

Never render one link per page. Use PaginationWindow.Build (Web/Common/PaginationWindow.cs): first page, last page, and the current page with one either side, with <govuk-pagination-ellipsis /> marking each skipped run.

A school with a few hundred pupils otherwise produced one link per page — hundreds of tab stops for a keyboard user to get past the list, and every number read aloud by a screen reader. Views/CheckYourPupilData/_Pagination.cshtml is the reference implementation, and the dev seed deliberately generates enough pupils (see below) for this to be visible locally.

No href="#" placeholders, ever (WCAG 2.4.4). If a destination does not exist yet, either build a stub page or don't render the link.

Privacy, Cookies, Accessibility statement and Guidance live as static markup in _Layout.cshtml, served by PrivacyController / AccessibilityController → Views/Privacy/Index.cshtml and Views/Accessibility/Index.cshtml.

Publishing an accessibility statement is a legal requirement under the 2018 regulations, so a CMS editor must not be able to retarget it, blank it, or leave it as a placeholder. Do not move these links back into an EditableContent block.

Content-block gotcha: IContentBlockService.EnsureAsync seeds defaultHtml only when no block exists for the key. Editing defaultHtml in a view is therefore inert on every environment whose database already holds that block. That is why the footer block was re-keyed to footer-support-and-guidance-v2 rather than edited in place; the old block is left orphaned (visible in /admin/content-blocks, no longer rendered) so hand-edited prose can still be recovered.

Journey pages must render exactly one heading

A question-flow page gets its <h1> from one of two places, and the config decides which. Page.cshtml renders the page-level <h1> from the page's title; JourneyViewModelBuilder promotes the single question to the heading instead (IsPageHeading) when the page has exactly one question and no title. So:

  • single question → leave title empty; the question is the heading.
  • anything else — several questions, or a Content, EvidenceUpload, ResultDetails or QualificationDetails page — → set a title.

Setting a title on a single-question page switches both headings off, and leaving it off a multi-question page never turns one on. Eight pages in Remove_KS4June.json were in the second state when the audit ran (#375). Either way the page still renders, validates and submits, and the browser title is fine, so nothing but a screen reader notices. Use pageTitle when the browser title has to differ from the heading — for instance to keep a pupil name out of it.

ResultDetails.cshtml and QualificationDetails.cshtml render the <h1> unconditionally, so a missing title there gives an empty heading rather than none — worse, not better. PupilSearch, ResultSearch and QualificationSearch build their own heading (a govuk-label-wrapper <h1> around the search input, or a hard-coded fallback) and are exempt.

Both halves are pinned in QuestionFlowValidatorAlignmentTests — SingleQuestionPages_LeaveThePageTitleEmpty_… and PagesWithoutASingleQuestionHeading_SetATitle_….

Landmarks

Every layout wraps its masthead in <header role="banner">. The GOV.UK header renders a plain <div class="govuk-header">, not a <header> element, so nothing in the masthead is a landmark unless the service supplies one. Without the wrapper, the GOV.UK home link and the phase banner's feedback link sit outside every landmark and are unreachable by landmark navigation (audit #374). _Layout.cshtml, _AdminLayout.cshtml and _ShareLayout.cshtml each open the wrapper before the _DfeHeader partial and close it after the last masthead element; on _Layout that means the phase banner is inside it too.

The header shows the DfE logo (DfE Header (Rebrand)). _DfeHeader.cshtml is hand-written GOV.UK Header markup with an <img> of department-for-education_white.png in place of the GOV.UK logotype. The <govuk-header> tag helper cannot show a different logo. The image is the only content of the home link, so its alt text is "Department for Education". On focus, site.css shows the black logo on the yellow focus background. _Layout and _AdminLayout pass the sign in and sign out links to _DfeHeader as its model. The partial shows them on the right of the band, in a <nav aria-label="Account">. The label makes this landmark different from the service navigation's "Menu" landmark. _ShareLayout passes model="null", so it shows no sign-in links. The attribute is required: a partial with no model attribute inherits the page's model, and the type check fails (#765).

Breadcrumbs render before <main>, never inside it. A breadcrumb is a repeated navigation block, so landing on it is exactly what "Skip to main content" exists to avoid (audit #377). Views/Page/Content.cshtml puts its breadcrumb in an @section Breadcrumbs, which _Layout.cshtml renders inside the Header section — after the banner landmark closes, before @RenderBody(). The section supplies its own govuk-width-container, because the layout renders it outside the template's.

The content page spacing rules key off body:has(.cpb-breadcrumbs) and the .cpb-content wrapper that Content.cshtml puts around the authored tree. They do not key off the breadcrumb position inside <main>. AccessibilityAuditViewTests pins these rules.

A link or button whose text repeats down a page carries a govuk-visually-hidden suffix naming its row. Identical accessible names give a screen reader user no way to tell one row's control from the next, and no way to pick one out of a list of links (audit #378, #385, #379).

  • Views/AmendmentRequests/Index.cshtml — Edit, View and Delete each carry request {reference} for {pupil name}. The reference number is in there because it is the only value guaranteed unique when one pupil has two requests.
  • Views/LandingPage/Index.cshtml — each window card's "Continue" carries to {window title}.

Decorative separators

A visible <hr class="govuk-section-break--visible"> used as decoration is aria-hidden="true". An <hr> maps to the separator role, and VoiceOver and TalkBack announce it — noise, when the heading either side already carries the structure (audit #386). This covers the landing page, the amendment requests page, _FileUpload.cshtml and the CMS Divider widget. The footer's govuk-footer__section-break is left alone: it is part of the GOV.UK Frontend footer markup.

Option groups

A radio or checkbox fieldset names its own hint and error through aria-describedby, and each option names its own hint on its own input. _Radio.cshtml had neither, so the group's hint and its error message were never announced with the group, and every option's SubLabel was rendered with an id that nothing referenced (audit #383). _Checkbox.cshtml and _Date.cshtml already described the group; both option partials now describe the per-option hint too.

The id list is built conditionally on what the view actually emits, per the rule below — an option with no SubLabel gets no aria-describedby at all rather than a dangling one. _Radio also carries data-module="govuk-radios", which is what GOV.UK Frontend's radios JS binds to.

Expandable sections

An expandable section is the GOV.UK Details component on a native <details>/<summary>. Never put role="button" on the summary, and never replace the element with a button-and-aria-expanded disclosure. Android TalkBack announces the summary as "disclosure triangle" with the expanded or collapsed state and the summary text (audit #388). That is TalkBack's name for the native role — Chromium exposes a <summary> as DisclosureTriangle — not text this service supplies, and it is the correct exposure: name, role and state are all programmatically determinable, which is what WCAG 4.1.2 asks for. Published tests record the same announcement at Chrome 64 (Android 8), Chrome 81 (Android 10) and Chrome 149 (Android 16), and with TalkBack 6.2 and 7.2, and that role="button" on the summary changes it to "button". Adding it changes how the element is exposed on every platform — O'Hara found macOS VoiceOver then drops the state and loses headings nested in the summary — and every screen reader and browser pairing would need re-testing. GOV.UK Frontend 5 ships the component as the native element with no JavaScript module.

The sections a school user meets are the four "How can I find a DfE number?" help panels on the KS4 Remove flow's DfE-number pages (questionHelpTitle in Remove_KS4June.json, rendered by Views/Journey/_Question.cshtml) and the "Have you checked your second late results file?" panel on the 16-19 results-issue page (Views/ResultIssue/Index.cshtml). The "What evidence do I need?" panel on the evidence pages was removed by #523 (the requirements show under a heading now), and the CMS guidance pages rendered no <details> on preproduction on 14 Oct 2026. The CMS sanitiser allows <details>, role and aria-expanded in rich text, so the rule applies to content authors too: a details in guidance content is left as the plain element, with no role. AccessibilityAuditViewTests.HelpPanel_IsANativeDetailsElement_WithNoAriaRoleOverride pins the markup of both views.

A link that opens a new tab says "(opens in new tab)" in its visible link text and carries target="_blank" rel="noopener". The words are visible, not in a govuk-visually-hidden span, so sighted users get the same warning as screen-reader users (GOV.UK Design System links guidance; WCAG 3.2.5). Two link sets follow it: the phase banner's feedback link (AB#301012) and the three links to the guidance — service navigation, footer and the home page card (AB#306103), pinned together by GuidanceLinksViewSourceTests.

rel is noopener alone, not the noreferrer noopener the GOV.UK guidance shows. noopener is what closes the window.opener hole; noreferrer would also strip the Referer header, which DfE Analytics records on every page view (RequestReferer) and which RefererPagePath reads for the feedback link's page_path. On a same-origin target there is nothing for the Referer to leak to, so it stays. Use noreferrer only for a link to another site whose owner should not see where the click came from.

One admin link is an exception on purpose. On the admin requests page, the "Zendesk Id" value links to the ticket in Zendesk (#780). The table is already crowded, so the link text does not say "(opens in new tab)". The link has target="_blank" rel="noopener noreferrer", because Zendesk is another site. A visually hidden suffix names the request reference, because each row has a link of this type. AccessibilityAuditViewTests checks this markup.

Two older link sets do not yet follow this rule and are follow-up candidates: Views/Journey/DuplicateCheck.cshtml and Common/DuplicateRequestMessages.cs put rel="noreferrer noopener" on the same-origin "View submitted request" link, and the four Get Information About Schools links in Data/QuestionFlows/Remove_KS4June.json carry target="_blank" with no rel and say "(opens in new window)".

File inputs are the JavaScript-enhanced GOV.UK File upload (#380)

Every <input type="file"> in a view is wrapped in <div class="govuk-file-upload-wrapper" data-module="govuk-file-upload"> (or rendered by <govuk-file-upload javascript-enhancements="true">). TinyMCE's image picker (file_picker_callback in ContentBlock, ContentPage/Edit and WikiEdit) creates its own temporary file input and clicks it from its own Browse button; it is out of this rule's scope. Dragon NaturallySpeaking cannot activate a native file input in Chrome by any voice command — Nuance calls web pages unsupported (alphagov/reported-bugs#35, closed not-planned in 2024), and Zoonou recorded it on Provide Evidence as #380. GOV.UK Frontend 5.9.0 added the enhanced component for exactly this; the app bundles 6.5.1 and initAll() runs it.

What the module does, and what that means for code around it:

  • It puts a real <button type="button"> in the wrapper, takes the input's id for the button, renames the input {id}-input and sets hidden on it. So #fileUpload, #files, #file, #bundle and #Schema are buttons: an error summary's href="#…" focuses the button, as GOV.UK intends, and a script must reach the input by name, never by the old id (evidence-upload-validation.js, storage-chunked-upload.js).
  • The button is named by the label through aria-labelledby, so the label[for=id] is required — the module throws without it — and the button's accessible name is the label plus its status ("No file chosen" or the file name) plus "Choose file or drop file". Dragon's "click Choose file" matches on it.
  • It copies the input's aria-describedby to the button once, at init, and syncs only disabled afterwards. A script that changes a description after load must change it on the input and the button (both scripts do, through a describedElements helper).
  • An error message injected by script goes before the wrapper, where the GOV.UK error message sits; inserted "before the input" it would land inside the wrapper, under the button.
  • Error styling keys off the form group: .govuk-form-group--error .govuk-file-upload-button. Keep adding govuk-form-group--error to the group.
  • Without JavaScript nothing changes: the native input is the no-JS path, exactly as before.

Do not use the auditor's suggested workaround — role="link" and tabindex="0" on the label with the input hidden — which changes the control's role for every assistive technology and leaves the GOV.UK pattern.

The design system notes one limit: a browser may refuse a scripted input.click() until the page has had a real click, so a Dragon user may need one mouse click first. Pins: AccessibilityAuditViewTests (one fact per view), EvidenceUploadDuplicateWarningViewSourceTests, E2E Journey/EvidenceUploadEnhancedControlTests and Admin/StorageBrowserChunkedUploadTests.

Upload progress and failures (storage browser)

The storage browser's upload-in-parts enhancement (wwwroot/js/storage-chunked-upload.js, #568) reports into a role="status" region that is in the page, empty, before any script runs, and it writes there only at milestones (start, each 25%, each file done, finish), so a screen reader is not fed one line per part. Progress is a native <progress> element (aria-label="Upload progress") with a visible percentage beside it, updated every part and hidden from screen readers, so progress is never shown by colour alone and the status line carries the announcements. A failure builds the GOV.UK error summary (role="alert", Error: prefix, and a message that says what to do: "The selected file must be smaller than 2GB.", "Your session has ended. Sign in again and upload the file again." or, for anything else, "The upload did not complete. Upload the file again.") and moves focus to it. Focus moves to the Cancel upload button as soon as an upload starts, so it is not lost when the submit button is disabled, and cancelling returns focus to the status line. The submit button says Uploading… while disabled and Cancel is a real button. Without JavaScript the form posts as it always did and the hint states the smaller limit. AccessibilityAuditViewTests pins the markup.

Testing

The reusable pieces are unit tested — PageTitleTests, PaginationWindowTests, QuestionPartialModelDescribedByTests. Anything new that encodes an accessibility rule in C# should get the same treatment rather than being verified by eye alone.

Rules that live in markup rather than in C# are pinned by AccessibilityAuditViewTests (tests/.../Web/), which reads the .cshtml as text and asserts on the source — the same hostless pattern as LayoutRenderTests. Each fact names the audit ticket it covers. Nothing else in the build notices a dropped attribute or a moved element, so add a fact there whenever you fix an accessibility defect in a view.

Dev seeding is sized to expose these issues locally: SeedPupilData writes 120 included + 120 non-included pupils per school per window (PupilsPerGroup), well past the pupil list's page size of 10, so the pagination window and its ellipses are exercised on every local run. Don't shrink that back to a page or two.