A component that works in a React app doesn't automatically work when you reuse it elsewhere in the React ecosystem. I moved a design-system layout component from a React app into a Next project and it broke. The problem wasn't that Next couldn't render the component; it was that the component assumed a particular ownership and composition model. That assumption affected its accessibility too.
The component and its contract
The component was a layout component. It provided a skip link, a header with a main menu, and a <main> containing the page title and body.
Its accessibility depended on a concrete relationship. The skip link targeted the page's h1, the h1 had a matching id and tabIndex={-1} so it could receive programmatic focus, and the heading sat inside <main>.
The skip link was intentionally targeting the page heading rather than the <main> element. The desired focus destination was the beginning of the page's meaningful content, where the page title provided an immediate orientation point.
Activate the skip link and focus lands on the page title, past the persistent header and menu. A clear page-level heading, the correct landmark, and focus where it should be.
That worked, and it kept working until I tried to reuse the component.
Reuse exposed the composition assumption
In the original React app, the layout owned everything in one composition. The persistent chrome, skip link, header, and <main>, and the per-page content, the h1 and body, lived together.
That is perfectly reasonable when the application controls how the whole tree is composed.
Next doesn't compose that way. In Next's App Router, the persistent layout and the route-specific page have different ownership and lifecycle boundaries. The layout persists while the page content changes between routes. The layout receives that route-specific content through children.
So I couldn't use the component unchanged as the Next layout because it assumed it owned both sides of that boundary.
Trying to work around that assumption created an awkward choice. Either the persistent layout had to know about the page-specific heading, or the page content had to somehow reach back into the layout to establish the accessibility relationship.
Neither was a good component contract.
The problem wasn't simply that the component was "incompatible with Next." Its composition assumptions didn't survive a different rendering model.
And when composition assumptions include accessibility relationships, those relationships can break along with the composition.
The contract has to become an interface
The fix was to stop treating the layout as one indivisible thing.
It became two components: a shell and the content that fills it.
The shell owns the skip link, the header, and the <main> landmark. The content owns the page heading and body.
// Shell: owns the skip link and the <main> landmark, and renders a slot.
export const Layout = ({
children,
mainMenu,
headerActions,
headingId = "content-heading",
}) => (
<div className={styles.layout}>
<SkipLink
label="Skip to Content"
targetId={headingId}
className={styles.skip}
/>
<header>
{mainMenu}
{headerActions}
</header>
<main className={styles.main}>
{children}
</main>
</div>
);
// Content: owns the focusable heading the skip link resolves to.
export const PageContent = ({
pageTitle,
children,
headingId = "content-heading",
}) => (
<div className={styles.content_container}>
<h1
id={headingId}
tabIndex={-1}
className={styles.content_heading}
>
{pageTitle}
</h1>
<div className={styles.content_body}>
{children}
</div>
</div>
);
The important change isn't just that there are now two components. It's that the boundary between them is explicit.
The skip link needs a focus target.
The focus target needs a stable identity.
The target needs to be the page heading.
And that heading needs to be inside the main content.
Those are accessibility requirements of the composition, not implementation details hidden inside one component.
The shared ID used to be a magic string hardcoded in both places. Now that ID represents the interface between the two halves, so it belongs in the API.
Both components use the same default:
headingId = "content-heading"
which means they line up without additional configuration. If a consumer needs a different ID, it can provide one to both components.
That makes the relationship visible rather than relying on a convention that consumers have to discover.
There is still a limitation: an API that exposes a contract doesn't necessarily enforce it. A consumer can pass different IDs to the two components, or render multiple instances with the same default ID.
A more sophisticated design-system implementation could enforce the relationship structurally, for example, by generating the ID in the shell and providing it to the content through context. But even when the contract remains a consumer responsibility, making it explicit is a significant improvement over hiding it in two components that happen to agree on a magic string.
Why this makes the component more portable
React-based frameworks ultimately compose components into a rendered DOM tree, but they differ in how they establish the boundaries between persistent UI and route-specific content.
That's where the refactoring helps.
In a plain React application, you can compose the components yourself:
<Layout>
<PageContent pageTitle="Dashboard">
...
</PageContent>
</Layout>
In Next's App Router, the persistent shell can live in layout.tsx:
import { Layout } from "@/design-system";
export default function RootLayout({ children }) {
return (
<Layout>
{children}
</Layout>
);
}
while the route-specific content lives in page.tsx:
export default function Page() {
return (
<PageContent pageTitle="Dashboard">
...
</PageContent>
);
}
The same separation works with React Router. A parent route can own the persistent shell and render an <Outlet />, while the child route provides the page-specific content.
The composition mechanism is different, but the boundary is the same: the shell owns the persistent structure, and the route supplies the content that fills it.
That is the boundary the original component was missing. Once the component is split along that boundary, the framework can decide how the content gets there without changing the accessibility relationship between the skip link and the page heading.
The accessibility travels with the composition. The skip link still resolves to the heading, whether the two halves are composed directly in React, through children in Next, or through an <Outlet /> in React Router.
Accessibility is part of the composition contract
This is the part that is easy to miss when designing reusable components.
Accessibility isn't always something contained entirely within a single component.
Sometimes it is a relationship between components.
A label and its form control.
A button and the dialog it opens.
A tab and its tabpanel.
A skip link and the content it moves focus to.
When two components participate in one of those relationships, the relationship is part of their API whether the component author documents it or not.
If it remains implicit, reuse becomes fragile.
A consumer has to know that one component renders an element with a particular ID. They have to know that another component expects that ID. They have to preserve the relationship when they change the composition.
Making the relationship explicit turns an implementation detail into a contract.
And once the contract is explicit, the framework has more freedom to determine how the components are composed.
That's what makes the components portable.
The point
The lesson isn't that React components need special versions for every framework.
It's that reusable components shouldn't unnecessarily assume who owns the composition seam.
A persistent shell and route-specific content may be composed directly in one React application, through children in Next's App Router, or through an <Outlet /> in React Router. Those mechanisms differ, but the underlying architectural relationship is similar.
Design the component around that relationship rather than around one particular way of composing it.
And make the accessibility relationships explicit while you're doing it.
A component's accessibility isn't separate from its reusability. If its accessible behavior depends on assumptions about how its pieces are composed, those assumptions are part of its contract.
A component is only as portable as the accessibility contract that survives being composed differently.
Top comments (0)