Aligning Components with Custom HTML Attributes
Leveraging CSS Attribute Selectors in Your Design System For Easy Positioning
Over the weekend, I found myself updating my personal React template repo and wanted add a few components to the pattern library.
I love the look and feel of HeroUI, so I initially built the UI with it. Since then, it’s introduced breaking changes pretty much every time I’ve done a dependency update even in minor versions. I finally ripped it out a few weeks ago and have been referring to HeroUI source code and documentation occasionally as I rebuild my core components.
Keeping changes to props (i.e. the contract) minimal is crucial to ensuring the scope stays manageable, so I pulled up the HeroUI docs as I started building out my new <Badge />
component on Saturday.
One thing I noticed right off the bat is that the HeroUI <Badge />
component wraps the <Avatar />
component and not the other way around:
<Badge color="primary" content="5" placement="bottom-right">
<Avatar radius="md" size="lg" src="..." />
</Badge>
This implementation makes sense but feels rudimentary. I wanted to know if I could achieve something that expresses the presentation logic as an avatar with a badge rather than a badge over an avatar, something like this instead:
<Avatar
badge={<Badge color="primary" placement="bottom-right">5</Badge>}
radius="md"
size="lg"
src="..."
/>
We might have a traditional badge, with a number or icon representing something of priority like notifications or a word like “PRO”. We might also use it to indicate online status or selection within a group, so being able to change the badge position is actually a requirement!
It just so happens that I had a standardized implementation for an alignment
prop lying around that could work in this case in theory so I decided to break the existing BadgeProps
contract.
This approach is nothing new or novel— any Engineer who uses CSS somewhat regularly or at least has deeper than a surface-level understanding of it could ideate a solution like this on their own, but I figure it’s worth sharing in the hopes that someone finds it helpful!
Alignment
Tokens
As tokens, alignment
prop values describe a position relative to one or two perpendicular sides of a box, in this case the Box Model.

If you’re wondering why I’m calling it a “9-anchor” system when there are only 8 visible anchors in the screenshot, it’s because we want to use these for positioning rather than sizing so we can imagine the 9th anchor being directly in the center.
Since we’re operating on a 2-dimensional plane, I broke these tokens down into two enums- one for each axis:
export enum HorizontalAlignment {
Left = 'left',
Center = 'center',
Right = 'right',
}
export enum VerticalAlignment {
Bottom = 'bottom',
Center = 'center',
Top = 'top',
}
If you’re a technical design nerd like I am, you may have the urge to yell at your screen that VerticalAlignment.Center
should be VerticalAlignment.Middle
! And you wouldn’t necessarily be wrong, but in this implementation I’ve found it’s actually better not to distinguish between horizontal and vertical center. Keep reading and you’ll see why!
Anyhow, these two enums are fed into token types like so for prop signatures:
export type ScalarAlignment = `${VerticalAlignment}` | `${HorizontalAlignment}`;
export type CompoundAlignment = `${VerticalAlignment}${'-' | ' '}${HorizontalAlignment}`;
export type Alignment = ScalarAlignment | CompoundAlignment;
Notice how we’re using template string literal types. Using a string enum in a template string literal type expands the enum type into a union of its values. This allows our Product Developer (so… future me) to use a discrete horizontal or vertical alignment value like “top”, “center”, “left”, etc. or a compound horizontal and vertical value separated by either a dash or space e.g. “top-center”, “bottom right”, etc.
Provided to a component, the value is simply passed directly to the root element as an HTML data-*
custom attribute:
<span
className={css}
style={{ ...style, borderRadius: circle ? '50%' : radius }}
data-alignment={alignment}
ref={ref}
>
{children ? children : <> </>}
</span>
Leveraging CSS Attribute Selectors
Now that we have a custom attribute to select, we can control our badge position externally.
You can easily make this DRY in SCSS with a mixin or placeholder selector. For this example I’m using avatar’s stylesheet, but may end up moving most of the below to src/App.css
since I’m using PostCSS Modules:
// Avatar.module.css
.avatar {
position: relative;
[data-alignment] {
position: absolute;
}
[data-alignment$="left"] {
left: 0;
}
[data-alignment$="right"] {
right: 0;
}
[data-alignment^="top"] {
top: 0;
}
[data-alignment^="bottom"] {
bottom: 0;
}
[data-alignment]:not([data-alignment^="top"]):not([data-alignment^="bottom"]):not([data-alignment$="left"]):not([data-alignment$="right"]):not([data-alignment*="center"]),
[data-alignment$="top"],
[data-alignment$="bottom"],
[data-alignment$="center"] {
left: 50%;
transform: translateX(-50%);
}
[data-alignment]:not([data-alignment^="top"]):not([data-alignment^="bottom"]):not([data-alignment$="left"]):not([data-alignment$="right"]):not([data-alignment*="center"]),
[data-alignment^="left"],
[data-alignment^="right"],
[data-alignment^="center"] {
transform: translateY(-50%);
top: 50%;
}
}
Let’s break this down:
.avatar {
position: relative;
[data-alignment] {
position: absolute;
}
This code helps us position the component relative to the underlying content. The second rule in particular makes it easier to align the component by giving us a mechanism that lets us start at 0 on a pixel coordinate grid from each side of the content.
It’s worth noting that because we’re using absolute positioning, position: relative;
in the containing component is required for this to work properly.
[data-alignment$="left"] {
left: 0;
}
[data-alignment$="right"] {
right: 0;
}
These selectors target any alignment values ending in “left” or “right”. We know compound values always end with a horizontal alignment, so this covers “*-left”, “*-right”, “left”, and “right”.
[data-alignment^="top"] {
top: 0;
}
[data-alignment^="bottom"] {
bottom: 0;
}
These selectors do the same thing but for vertical alignment. Compound values always start with a vertical alignment, so we use ^=
instead of $=
to match the beginning of the string rather than the end. So now we’ve also taken care of “top-*”, “bottom-*”, “top”, and “bottom”.
Here’s where it gets a little weird:
[data-alignment]:not([data-alignment^="top"]):not([data-alignment^="bottom"]):not([data-alignment$="left"]):not([data-alignment$="right"]):not([data-alignment*="center"]),
[data-alignment$="top"],
[data-alignment$="bottom"],
[data-alignment$="center"] {
left: 50%;
transform: translateX(-50%);
}
[data-alignment]:not([data-alignment^="top"]):not([data-alignment^="bottom"]):not([data-alignment$="left"]):not([data-alignment$="right"]):not([data-alignment*="center"]),
[data-alignment^="left"],
[data-alignment^="right"],
[data-alignment^="center"] {
transform: translateY(-50%);
top: 50%;
}
Being the astute reader you are, you probably noticed that the attribute selector operators have flipped, so now we’re using ^=
for horizontal alignment $=
for vertical. We already know that a compound value always begins with a vertical alignment and ends with a horizontal alignment, so by swapping the operators we can isolate discrete values, and that’s what this code does.
As a requirement, whenever we use a discrete alignment value like “top” or “left”, it should be evaluated as a compound value where the missing axis is assumed to be “center”. So “top” should be evaluated as “top-center”, “left” should be evaluated as “center-left”… you get the idea!
This styling centers the alignable component both when “center” is explicit and implicit. And this right here is why we don’t distinguish between horizontal “center” and vertical “middle”! Plus, semantically it’s just much nicer to be able to say “center” rather than “middle-center”.
Now, for an avatar component, having a badge in the direct center feels like a potential use case I’d disregard as YAGNI. But who knows, I’ve seen stranger things out there.
[data-alignment]:not([data-alignment^="top"]):not([data-alignment^="bottom"]):not([data-alignment$="left"]):not([data-alignment$="right"]):not([data-alignment*="center"])
Lastly, I want to point out this line. It’s a part of both of these last two selectors and it plays in important role in graceful degradation by ensuring that any invalid value renders the component in the direct center, as if the value were “center-center” or just “center”.
I’ve found a few edge cases (like alignment=“bright”
) that can break this but for the most part it’s pretty solid, I’d feel fine handing this off to QE at this point if I did this at Vivid Seats. That said, I haven’t even started with animations yet and I’ll be nervous about transform: translate;
when I do. If I want to be able to animate the badge with a slide in/out, I may have to figure out another approach.
I’m guessing that’s why HeroUI’s <Badge>
is the container.