CSS and Sub-Pixel Rendering: The Case of the Clipped Border
Have you ever encountered a border in your UI that seemed cut-off? I know I have, and it can be a tricky problem to solve. I’m here to work through the issue and show you how several options, some available and some coming in a future spec, can help you resolve this pesky problem.
The issue
Our problem is visible on a ring/border that is added via a pseudo-element and the following are true:
- Element is focused and has an animation that uses
scale()
- Appearing while active
Not initially known, the scale()
would ultimately be the culprit.
TL;DR — There are several possible solutions:
- use a keyword like,
thin
,medium
, orthick
- use a future spec property
round()
- promote the property to its own layer with
will-change
.
border-width
keywords
TL;DR — using border-width
values like thin
, medium
, or thick
, (1px, 3px, and 5px respectively) will render a consistent border across browsers.
Browser Inconsistencies
Sub-pixel rendering issues are not consistent across browsers. The problem seen in our image can be seen in Chrome and Edge, but not Safari or Firefox. Different browser rendering engines handle calculations differently.
In Chrome, you’ll notice rounding in the element’s content-box
.
Viewing the same element in Safari, you’ll notice this element’s content-box
is rounded to the nearest whole number.
Fortunately, the border-width
property allows you to use keywords like thin
, medium
, and thick
. Those values equate to 1px
, 3px
, and 5px
respectively. Using the keyword values resolves the clipped border since the keywords will work properly regardless of how the engine renders the content-box
.
Unfortunately, our border-width
needs to be 2px
per our designs, so although this solution will fix our issue, no keyword value equals 2px
.
round()
TL;DR — In the future, we’ll be able to round computed values to the nearest pixel to ensure consistent element widths across browsers: round(nearest, var(--width), 1px)
.
Looking at images in our previous section, we see two things happening; it’s working when the content-box
is rounded to the nearest pixel and breaks when the content-box
isn’t an integer.
During discovery on this issue, one future property that seems to resolve this issue is the round()
in the CSS Values and Units Module Level 4 working draft. Per the spec,
“The round(<rounding-strategy>?, A, B) function contains an optional rounding strategy, and two calculations A and B, and returns the value of A, rounded according to the rounding strategy, to the nearest integer multiple of B either above or below A.”
The idea being that since browser calculation rounding is the problem we can round to the nearest whole pixel in CSS, round(nearest, var(--width), 1px)
or round(var(--width), 1px)
, to create a consistent calculation across browsers. Given that this property is only in a working draft, we needed to find a different solution.
Using the GPU
TL;DR — will-change: transform
resolves our transform issue.
Without going into detail as this topic requires its own post, this is a painting issue. Paints are triggered when an element’s geometry changes shape or if properties like background
, color
, or box-shadow
, are updated. Paints can be done by constructing a single image/layer or a composition of smaller images/layers.
In our example, we use transform
and opacity
. The way the browser renders these two properties is in a single layer. Explore more about layers in this great article. For our use, we need to create two layers separating opacity
and transform
. This separation is known as layer promotion.
Layer promotion is important because if something repeats, you can choose to place it on its own composition layer to keep from affecting other elements.
Our transition involved two properties: opacity
and transform
. During discovery, I noticed that toggling the opacity
property would correct the clipped border while toggling the transform
property would reintroduce the clipped border.
/* before */
.sage-btn::after {
opacity: 0;
transform: translate3d(-50%, -50%, 0) scale(0.94);
transition: opacity 0.15s ease 0.05s, transform 0.2s ease;}
Since the scale()
property was not the only transition, transform: translate3d(-50%, -50%, 0) scale(1)
, the first thought was to get this transition on its layer using will-change
. Since any repaints for a particular element exist on the same layer, we first wanted to separate the transform
to its own layer through layer promotion to see if the rendering issue was resolved. Lucky for us, promoting transform
on its own layer wound up being our winning solution.
/* after */
.sage-btn::after {
opacity: 0;
transform: translate3d(-50%, -50%, 0) scale(0.94);
transition: opacity 0.15s ease 0.05s, transform 0.2s ease;
will-change: transform;}
⚠️ Be careful not to abuse promoting elements to their own layers. Every composite layer consumes additional memory. Excessive memory use will ultimately crash the browser. We don’t want to create one performance issue while trying to resolve another.
Conclusion
Although we were able to resolve our sub-pixel rendering issue by using a performance hack, our journey to the solution helped us to uncover other solutions that didn’t fit our case but could fit someone else’s. If your design calls for a border-width of 1px
, 3px
or 5px
, you know that using border-width
keyboards will ensure a consistent experience across browsers. It’s refreshing to see the types of mathematical functions that are slated to arrive. We only have calc()
now, but I’ve had great value with that prop. I can only imagine how the next batch of mathematical functions allows for even more flexibility while ensuring a consistent browser experience. Elevating problematic rendering compositions is a quick way to resolve painting issues, but don’t overuse since this operation can drain memory.