Photo by Will Francis on Unsplash

CSS and Sub-Pixel Rendering: The Case of the Clipped Border

Quinton Jason Jr
Kajabi UX
Published in
5 min readJul 30, 2021

--

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.

A button with a clipped border.

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, or thick
  • 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.

The content-box in Chrome displaying decimal values.

In Chrome, you’ll notice rounding in the element’s content-box.

The content-box in Safari displaying integer values

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;
}
Toggle the transform and opacity properties without will-change present.

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;
}
Toggle the transform and opacity properties with will-change present.

⚠️ 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.

--

--

Quinton Jason Jr
Kajabi UX

UXD @Kajabi. I make boxes on a screen. Sometimes they have words in them. Sometimes they have colors. Sometimes they have pictures.