Hyrum's Law in Design Systems
Jan 11, 2026How to handle versioning in Design Systems is a question as old as Design Systems.
The question that too often isn't asked though, is what exactly is it that's being versioned?
This matters because of Hyrum's Law:
With a sufficient number of users of an API, it does not matter what you promise in the contract: all observable behaviors of your system will be depended on by somebody.
Illustrated, as ever, by XKCD.
In this post I'll be discussing this from an engineering standpoint—the design side (and whether this should be synchronised with engineering) is left as an exercise to the reader, as will determining what is a visually breaking change.
The Notification
Here is our notification we'll be using to explore this:
This may be represented with the following React signature:
declare const Notification: (props: {
title: string;
body: string;
actions?: NotificationAction[];
onClose?: () => void;
}) => React.JSX.Element;
It's pretty clear what the API surface area is here, right? The four props we pass in. If those change, we need to consider the impact on users as to whether this is a breaking change or not.
But wait!
The innocuous looking return type of React.JSX.Element is masking all kinds of implementation details that will
be accessible to downstream users for nefarious usages.
<div role="alert" class="notification">
<div class="notification-content">
<h3 class="notification-title">Notification Title</h3>
<div class="notification-body">Notification body</div>
<div class="notification-actions">
<button class="notification-action">Action!</button>
</div>
</div>
<button aria-label="Close notification" class="notification-close">x</button>
</div>
The DOM structure and class names sit there, tempting downstream users to use them to customise or interrogate the component.
When you decide to switch from a flex-based layout to a grid-based layout - with no change to the function signature above - your DOM will look like this:
<div role="alert" class="notification">
<h3 class="notification-title">Notification Title</h3>
<div class="notification-body">Notification body</div>
<div class="notification-actions">
<button class="notification-action">Action!</button>
</div>
<button aria-label="Close notification" class="notification-close">x</button>
</div>
And all of your users relying on there being an element matched by .notification-content - whether that be for
customisation or testing - will be rather confused, as this internal implementation
detail has now gone.
What to do?
Hyrum's law says that people are going to rely on these observable behaviours (ie, the DOM).
There are two approaches to take to improving this situation:
- Document a better alternative solution.
- Make the behaviours unobservable.
A better alternative
One of the common places that the internal implementation details of the DOM leak into downstream usage is in test selectors.
Long gone are the days that we should be using class names (or XPath selectors 😱) in our tests.
Modern web testing frameworks are principally based around ARIA roles—no more:
const closeButton = document.querySelector(".notification .notification-close");
Instead:
const closeButton = page
.getByRole("alert")
.getByRole("button", { name: /close notification/i });
The ARIA tree of your component is something that should be stable:
- alert:
- heading: "Notification Title" [level=3]
- text: "Notification description"
- button: "Action!"
- button: "Close notification"
That is; for a given input set of properties, the same ARIA tree will be generated. It is extremely unlikely for your downstream users that they will need to interact with or assert on any part of your component that is not easily and uniquely identifiable via the ARIA tree. After all, if the tests can't do it, then how are actual users going to interact with your ARIA tree.
Obfuscating the implementation
Given the prevalence of downstream users hooking into internal implementation details (class names and DOM structure), this change is something that can only really be done on greenfield projects.
The Shadow DOM is a sure-fire way to put up barriers to users interfering with styles—unfortunately its also a sure-fire way to open yourselves up to all kinds of accessibility and browser compatibility headaches.
A much simpler option is to obfuscate your class names, for instance by using the CSS Modules protocol. The degree of obfuscation is easily customisable, but even a light-weight 'hash the file contents and append local name' option should a. clearly advertise the instability of the class names and b. change frequently enough to reinforce that these values should not be used.
<div role="alert" class="_ab42c3_notification">
<div class="_ab42c3_notification-content">
<h3 class="_ab42c3_notification-title">Notification Title</h3>
<div class="_ab42c3_notification-body">Notification body</div>
</div>
</div>
This approach can also be extended to other CSS features that users may be attempting to hook into, such as CSS variables.
Of course, some classes (and CSS variables) will form part of the public API, and will be left unobfuscated.
Advertising the API
As things stand, I'm not aware of any nice way of encoding the above. Documentation will have to do much of the heavy lifting, along with regression testing to catch API breakages—Playwright offers ARIA snapshotting, which can be ported to other testing platforms without too much fuss.
/**
* Notification Widget
*
* @class-hooks
* - notification: add additional styling to the root notification
* @aria-tree
* - alert:
* - heading: <title> [level=3]
* - text: <description>
* - button: <action> <action> ...
* - button: <close button>
*/
declare const Notification: (props: {
title: string;
body: string;
actions?: NotificationAction[];
onClose?: () => void;
}) => React.JSX.Element;
But what if my users want to break things?
I'm under no illusion that this is a somewhat naïve approach. I've learnt never to underestimate the lengths to which downstream consumers will go to shoot themselves in the foot.
Neither of the two suggestions above will eliminate users hooking into internal implementation details and then complaining when you make a so-called 'breaking change' on something that was never part of the public API.
But by formalising your approach to this, you can at least have a sign to tap before you begrudgingly add in yet another extension point for customisation.
Bill Collins