Prologue
A while ago, I decided to develop a fully accessible main navigation component in React and write a series of articles documenting the steps it took to create a non-trivial accessible component.
In the last article, the focus was on wiring a theme into the foundational components that render valid HTML elements. This article focuses on creating the navigation structure and on transforming objects into a navigation component suitable for further work by developers for operability and by designers for perceivability.
Note: This article is one of a series demonstrating how to build a React navigational component from scratch while considering accessibility through the process. The articles are accompanied by a GitHub repository with releases tied to one or more articles; each building on the previous, until a fully implemented navigation component is complete.
Each release and its associated tag contain fully runnable code for the article. The code discussed in this article is available in the release. and may be downloaded at release 0.3.0. A page showcasing these base components may be run locally through this release.
While code examples are written in JavaScript for brevity, all actual code is written in Typescript and targets React 19.x. Examples use Next.js 16.x, which is not required to run the navigation component.
You can view the requirements for the Structure and Transformation Release along with previous requirements.
Follow along either by downloading the release and running the examples while examining the codebase, or by activating the link accompanying each code snippet to view the full file on GitHub.
-β
This article is fairly long. It contains code and explanations. The code for this release is displayed, and the corresponding GitHub source is linked. Use these anchor links to return to the discussions of the individual components.
Content Links
Introduction
Non-trivial component development requires progressively enhancing code through a layered approach. Each release builds on the previous release, adding accessibility, functionality, and even some styling to the earlier work. Consider building these components on a feature branch and merging into main only after they are complete.
With the base components completed, attention turns to the components necessary to render the nested structure of unordered lists contained in the landmark element. Screen reader accessibility and minimal target functionality via button activation are also added, resulting in a fully rendered navigation component that can be displayed in examples and used in testing.
All examples display the default choice for an uncontrolled component with full aria capabilities, along with some slight styling to provide developers with visual indications of placement. When run locally, the home page exposes three ugly, yet operable examples for development and testing. Taken together, the three examples cover all of the scenarios necessary to create a robust, uncontrolled navigation component.
Acceptance Criteria
Structure and Transformation Release
- AC 1 - The Navigation Component should be contained in a landmark region
- AC 2 - The navigation should be presented as a nested structure of unordered lists within the landmark region
- AC 3 - Lists should present with a role of "list", list Items should present with a role of "listitem".
- AC 4 - Buttons are associated with a sublist and indicate if the sublist is open or closed.
- AC 5 - The navigation component should be able to be presented as horizontal (desktop - default).
- AC 6 - When navigation is displayed in a horizontal layout, the first row of focusable elements should display as open by default.
- AC 7 - The navigation component should be able to be presented as vertical (mobile presentation).
- AC 8 - A visual indicator is used to represent the state of the sublist's expanded status.
- AC 9 - A link should contain a method of informing users which link corresponds to the current page for both screen and screen readers.
- AC 10 - A sublist may be toggled to open or close when the button associated with it is pressed.
- AC 11 - The state of a list's visible or hidden state should be easily perceived by any user
- AC 12 - Unexpanded sublists should be hidden from the screen, but available through the DOM when they are closed.
- AC 13 -A JavaScript object containing a properly formed object should be able to be transformed into properly formed ListItems and Subnavigation lists.
While most of the requirements were known during the initial requirements gathering phase, two more have been added.
AC 12 represents the decision to render unopen lists in the DOM while restricting their visibility on a screen. This decision and the reasons for it were made in the initial requirements gathering phase.
AC 13 requires a utility function that can take a JavaScript object and output a fully formed set of navigation lists and links to form the nested structure necessary.
Structure
Given the requirements, the following components are necessary and will be built in the following order:
- NavigationList: A wrapper around the List component that allows for children and designates whether the list itself is displayed on the screen.
- NavigationItem: Renders a Navigation Link within a ListItem and reveals the link referring to the current page. (aria-current).
- SubNavigation: Renders both a button and its accompanying sublist within a listItem. Handles displaying or hiding the sublist on the screen when the button is pressed.
- Navigation: Renders the entire navigation lists and sublists within a HTML element.
NavigationList
Acceptance Criteria Realized
- AC 3 - Lists should present with a role of "list", list Items should present with a role of "listitem".
- AC 5 - The navigation component should be able to be presented horizontally (desktop presentation).
- AC 7 - The navigation component should be able to be presented as vertical (mobile presentation).
- AC 12 - Unexpanded sublists should be hidden from the screen, but available through the DOM when they are closed.
export default function NavigationList({
children,
cx,
id,
isOpen,
...rest
}) {
const listProps = {
...rest,
id,
cx: classNames({ srOnly: !isOpen }, cx),
};
return (
<List key={`list-$id`} {...listProps}>
{children}
</List>
);
}
GitHub (release 0.3.0) - NavigationList.tsx
A NavigationList wraps around the previously created List component, which renders the structural HTML (AC 3). An unordered list (ul) has a default role of "list".
While not detailed in the code, an orientation prop is passed to the List component via β¦rest, that indicates whether the list is displayed horizontally or vertically (AC 5 and 7). This was placed into the List component during the base component functionality phase.
The isOpen prop controls the screen visibility of the specific list by applying the .srOnly class when the list is unexpanded and removing it when isOpen is true, satisfying AC 12. A screen reader-only class hides the content from the screen while still making it available to screen readers.
Because the transform utility iterates over an array, a key is passed to the List component.
NavigationItem
Acceptance Criteria Realized
- AC 3 - Lists should present with a role of "list", list Items should present with a role of "listitem".
- AC 9 - A link should contain a method of informing users which link corresponds to the current page for both screen and screen readers.
export default function NavigationItem({
cx,
href,
id,
label,
...rest
}) {
const currentPath = usePathname();
const pageURL = href.substring(0, 2) === "/#" ? currentPath + href : href;
const listItemProps: Omit<ListItemProps, "children"> = {
cx: cx,
id: id,
};
const linkProps: Omit<LinkProps, "children"> = {
"aria-current": returnTrueElementOrUndefined(currentPath === href, "page"),
"aria-label": `${label} navigation`,
href: pageURL,
...rest,
};
return (
<ListItem key={id} {...listItemProps}>
<Link {...linkProps}>{label}</Link>
</ListItem>
);
}
GitHub (release 0.3.0) - NavigationItem.tsx
While not a detailed requirement, and mostly useful for tests and examples, the system does check for anchors passed through, and if an anchor is the only href aspect passed through, the path of the currently loaded page is prepended to the URL, which is ultimately passed to the link. Care should be taken that any objects used in production do not allow the passage of "/#".
When a link is identical to the page currently loaded, the aria-current prop should be set to "page" (AC 9) but otherwise should be undefined. I'm using the NextJS function usePathname() to achieve this; your solution may be different.
Even though nothing in the acceptance criteria requires it, an experienced developer will add information to the link about its purpose. Adding "navigation" to the label helps satisfy WCAG 2.4.9 Link Purpose by guaranteeing anyone using a screen reader understands that the links in the elements list/rotor are navigation-related. The label is prepended to the "navigation" description, satisfying WCAG 2.5.3 Label in Name for those who use their voice to interact with the component.
Once again, a key is added to the ListItem component call since a ListItem can be rendered through an array map iteration.
SubNavigation
Acceptance Criteria Realized
- AC 4 - Buttons are associated with a sublist and indicate if the sublist is open or closed.
- AC 8 - A visual indicator is used to represent the state of the sublist's expanded status.
- AC 10 - A sublist may be toggled to open or close when the button associated with it is pressed.
- AC 11 - The state of a list's visible or hidden state should be easily perceived by any user
export default function SubNavigation({
children,
cx,
id,
label,
testId,
}) {
const [isSubListOpen, setIsSubListOpen] = useState<boolean>(false);
const handlePress = () => {
setIsSubListOpen(!isSubListOpen);
};
const buttonProps = {
"aria-controls": id,
"aria-expanded": isSubListOpen,
"aria-label": `${label} subnavigation`,
onPress: handlePress,
};
const iconProps = {
IconComponent: ChevronRightIcon,
isSilent: true,
};
const listItemProps = {
cx: cx,
};
const navigationListProps = {
id: id,
isOpen: isSubListOpen,
testId: testId && `${testId}-list`,
};
return (
<ListItem key={id} {...listItemProps}>
<Button {...buttonProps}>
{label}
<Icon {...iconProps} />
</Button>
<NavigationList key={`list-${id}`} {...navigationListProps}>
{children}
</NavigationList>
</ListItem>
);
}
GitHub (release 0.3.0) - SubNavigation.tsx
The entire subnavigation component is wrapped in a ListItem, which contains a Button and a NavigationList. This is the basis for creating a properly nested list within the navigation component. As with the NavigationItem component, the ListItem must include a key to meet React's requirements when generating the navigation from a JavaScript object.
A button that controls another element, in this case the NavigationList component, associates itself with the NavigationList via the aria-controls attribute, which links to the NavigationList's id. Buttons also use the aria-expanded attribute to denote whether the item it controls is visible (aria-expanded ="true") or hidden (aria-expanded="false"). It's one of the few aria attributes where both true and false give meaning, and so it is always a boolean value. Additionally, an icon is included as a child of Button, indicating to screen-viewing users that the item is not a link and, when toggled, rotating between 0 and 90 degrees to indicate the list's visibility, satisfying AC 8. The actual visibility of the list can also be said to achieve AC 8.
Along with their link counterparts, buttons are available in a different collection of a screen reader's list elements/rotor, and appending "subnavigation" satisfies the same WCAG success criterion.
Navigation
Acceptance Criteria Realized
- AC 1 - The Navigation Component should be contained in a landmark region.
- AC 2 - The navigation should be presented as a nested structure of unordered lists within the landmark region.
- AC 6 - When navigation is displayed in a horizontal layout, the first row of focusable elements should display as open by default.
export default function Navigation({
children,
cx,
isOpen = true,
label,
orientation = "horizontal",
...rest
}) {
const navigationProps = {
"aria-label": label,
className: cx,
};
const navigationListProps = {
...rest,
isOpen,
orientation,
};
return (
<>
<nav {...navigationProps}>
<NavigationList {...navigationListProps}>{children}</NavigationList>
</nav>
</>
);
}
GitHub (release 0.3.0) - Navigation.tsx
The navigation component wraps the first call to the NavigationList component within the landmark, indicating that the component represents navigation and includes a label that identifies it to a screen reader. (AC 1). An uncontrolled component will always return the initial state of the top row as open and set an orientation of horizontal on the first row (AC 6), which is passed through to the NavigationList.
Minimal styling is applied to the navigation component to rotate the icon when a button is toggled.
navigation.css
@layer common-components {
nav {
& svg {
left: calc(var(--sp-px) * 8);
position: relative;
}
& button[aria-expanded="true"] svg {
rotate: 90deg;
}
}
}
GitHub (release 0.3.0) - navigation.css
Most styling of a navigation component will take place elsewhere, but spacing and rotation of the icon are best handled within the component itself. As I mentioned before, I find it useful to style based on aria attributes rather than adding a class, letting my CSS add a small accessibility enforcement.
Transformation
When a developer manually creates navigation code can look similar to:
<Navigation>
<NavigationItem id="item-one" href="/one" label="Item One" />
<NavigationItem id="item-two" href="/two" label="Item Two" />
<SubNavigation id="Sub-One" label="Sub Nav One" >
<NavigationItem id="item-three" href="/tree" label="Item Three" />
</SubNavigation>
</Navigation>
Of course, creating all the variants for examples and testing can be tedious, and realistically, the component's information will be read from a JavaScript object, whether from a static JSON file or an API via GraphQL. I also like standardizing examples across testing and browsers to make it easier to identify and fix issues.
[{
"label": "Tales",
"id": "tales-menu",
"href": "",
"menu": [
{
"label": "Search",
"id": "search-menu",
"href": "#",
"menu": [
{
"label": "Basic Search",
"id": "basic-search",
"href": "/#basicsearch"
},
{
"label": "Advanced Search",
"id": "advanced-search",
"href": "/#advsearch"
}
]
},
{
"label": "All Stories",
"id": "all-stories",
"href": "/#all-stories"
},
{
"label": "All Commentary",
"id": "all-commentary",
"href": "/#all-commentary"
},
{
"label": "Find Your Next Story",
"id": "find-next-story",
"href": "",
"menu": [
{
"label": "By Storyteller",
"id": "by-storyteller",
"href": "/#by-storyteller"
},
{
"label": "By Era",
"id": "by-era",
"href": "/#by-era"
}
]
}
]
},]
JSON Navigation Example GitHub (release 0.3.0) - multiple-lists-buttons.json
A subset of the multiple-lists-buttons.json file is detailed above. Upon examination, any link will have a valid href, and any button will contain a menu array. While the href attribute on a button isn't used, it must still be present to conform to the object type. Theoretically, subnavigation objects may be nested indefinitely, but for testing purposes, only two levels are featured.
The transform utility will take a full object and return the appropriately nested components to be rendered within the Navigation component.
Acceptance Criteria Realized
- AC 13 - A JavaScript object containing a properly formed object should be able to be transformed into properly formed ListItems and Subnavigation lists.
export function transformNavigation(
navigationArray,
testId,
) {
return navigationArray.map((item) => (
<Fragment key={`navigation-${item.id}`}>
{item.menu ? (
<SubNavigation
key={item.id}
id={item.id}
label={item.label}
testId={testId && `${testId}-${item.id}`}
>
{transformNavigation(item.menu, testId)}
</SubNavigation>
) : (
<NavigationItem
id={item.id}
key={item.id}
label={item.label}
href={item.href}
testId={testId && `${testId}-${item.id}`}
/>
)}
</Fragment>
));
}
GitHub (release 0.3.0) - transformNavigation
The transform utility is recursive, calling itself for objects contained in a menu array. Other than recursion, it's fairly straightforward. Loop through the object; if it contains a menu, render the Subnavigation component, then call itself to render the children defined in the menu object. If the item mapped does not have a menu, render a NavigationItem component.
When run in examples, the transformation returns a full navigation menu. And while it's obvious that a lot of work still needs to be done, there's now a solid foundation to work with.
Summary
With the transform utility, examples are now available in the release with just enough CSS to make operability feasible. Work can begin on horizontal and vertical menu styling.
At this moment, the navigation component meets screen reader perceivability requirements. It can be used via the keyboard with the Tab Key and the Enter or Space keys to toggle the sublist availability. All links and buttons are available in the screen reader elements list and correctly denote their purpose. Not bad for just a skeleton; in fact, I know several teams that would consider the navigation component complete at this point. I'm not one of them, though.
Next up will be the first of three releases dedicated solely to keyboard handling, concentrating on keyboard navigation through a single list.


Top comments (0)