Coloring the WebKit Browser Bars

A few tips for vastly improving the appearance of your website for mobile (and some desktop) WebKit end-users.

evan kirkiles
9 min readJun 6, 2023

In a recent effort to redesign my personal website, I opted for a high-contrast two-column layout. The first column’s background I chose to be white, and the second column’s background would be black, with the two columns collapsing vertically on smaller devices. The background-color of the body of my document’s body is a plain white, which will be important later. It’s a simple design:

The desktop display of my website in Safari’s light mode, compact view.

Yet as soon as I visited the site on XCode’s mobile device simulator to view how it would appear on mobile iPhone devices, I immediately noticed something that didn’t quite fit my vision. After scrolling down into the “Gallery” section of the landing page, where I opt for white text on a black background, I was left with two visually jarring white bars on the top and bottom of the viewport.

Left: Initial View, Right: Scrolled View

Clearly, I want the status and navigation bars to fit the black background color of the second column of the page. Yet at the same time, I’d like them to be white while the first column is still in view. So how do we do that?

Through the rest of this article, I’ll run through the quirks that exist in WebKit’s coloring of these native elements, and hopefully help you elevate the appearance of your own website, dynamically, allowing a more native feel on WebKit devices.

A Native Look and Feel

On mobile browsers, your actual site only takes up around 85–90% of the screen. At any point in time, that other 10–15% of the screen is occupied by a native navigation bar (where the website’s URL lives) and status bar (where the notch and battery lives). The color of these bars in Safari / WebKit, unlike Chromium, is directly settable. However, where they inherit their styles from is a bit nebulous, and can be confusing at first.

The Status Bar (“theme-color” meta tag)

The color of the top status bar follows the below hierarchy, in order of precedence:

  1. The theme-color meta tag in your HTML’s head, e.g. <meta name="theme-color" content="#f0f0f0"> . This also responds to JavaScript updates if you set it manually.
  2. The theme-color specified in your manifest.json , which is specifically targeted for PWAs. As this file is not dynamic, i.e. it is only loaded by your browser once, this is the least flexible option and I often forgo it, removing the setting from my manifest.json.
  3. Lastly, your document’s body’s background-color in CSS. Using background-color also enables CSS transitions. However, there is a caveat in using background-color, which will come up when I go over the navigation bar’s style hierarchy.

For example, by setting the theme-color meta tag to a value of #000000 , we can achieve the following results:

Coloration of the status bar by setting the meta “theme-color” tag

That looks a lot better for the top status bar—and of course, we can add a bit of JavaScript to update the theme-color meta tag to the black when we scroll down the page…

// A simplification of a scroll-based theme-color changer
window.addEventListener('scroll', () => {
const metaTag = document.querySelector('meta[name="theme-color"]');
if (window.scrollY / window.innerHeight > 0.8) {
metaTag.setAttribute("content", "#000000");
} else {
metaTag.setAttribute("content", "#ffffff");
}
}, false);

…allowing it to start out as white at the top and jump to black when we hit the “Gallery” sticky bar. But the lower, minimized navigation bar doesn’t change color at all. To understand why, let’s go over that bar’s style inheritance hierarchy.

The Navigation Bar (“background-color” CSS property)

The navigation bar actually inherits its color from a single place: the background-color on the document body. Now, remember that the status bar can also inherit its color from the body background-color . This means that we can easily synchronize the two by removing (a) the theme-color meta tag and (b) the theme-color setting in our manifest.json , and instead set the background-color of the body to #000000:

Tada! A much nicer experience for the Gallery section.

Let’s also port over our scroll-based JavaScript code to update our document’s body’s background color instead of the now-removed meta theme-color tag—

// A simplification of a scroll-based background color changer
window.addEventListener('scroll', () => {
if (window.scrollY / window.innerHeight > 0.8) {
document.body.style.backgroundColor = "#000000";
} else {
document.body.style.backgroundColor = "#ffffff";
}
}, false);

—giving us the desired ambient effect of white at first, and then black in the gallery. Our method also supports smooth CSS transitions! As of now, this already looks great…

…but we can do even better.

The Break Up

Our working solution looks good in the screenshots provided, but in the brief transitory scroll position where the top part of the screen has one main color and the bottom half has another, the bars still leave something to be desired:

Notice the white minimized navigation bar. Not perfect!

Notice the white navigation bar on the black background in the lower portion of the screen. Given that the two bars inherit styles from different hierarchies, we could hypothetically break up their coloration—having the lower navigation bar always match the specified color of the element in the bottom of the screen, and having the upper status bar always match the color of the element at the top of the screen.

Implementing Independent Bar Colors

So let’s think. The lower navigation bar needs to use the background-color CSS property. Thus, if we use the theme-color meta tag to control the color of the status bar, we can effectively separate the coloring of the two elements and control them dynamically. Yet here’s where it gets sticky, for two reasons.

  1. For whatever purpose, the color of the minimized navigation bar actually only updates and reads from the background-color CSS property when (a) the user scrolls and goes from a maximized to a minimized navigation bar, or (b) when the color of the status bar changes. This means that if we’re decoupling the status bar’s color from the navigation bar by using the meta theme-color tag, changing the color of the background-color CSS property isn’t enough to trigger a repaint of the navigation bar—we need to get the status bar to repaint as well. And as the colors are diff’d to determine when the status bar needs to repaint, this means we need to change the theme-color to a different color.
  2. Further, because we are now controlling the status bar’s color with the HTML theme-color meta tag, we don’t have access to a smooth CSS transition for its color—we’ll need to tween between the two colors manually, using JavaScript. And because the navigation bar’s color only updates when the status bar repaints, that also rules out CSS-based color transitions for the navigation bar unless it is synced with the status bar. You’ll notice if you set a transition on the background-color of the body, when you change the status bar’s color, the navigation bar samples the interpolated background-color value at the current point in time, not all throughout.

All that being said, it is still possible to decouple the two native browser bars. To detect when each bar needs to update, I opted to use two IntersectionObservers—one for the top of the screen and one for the bottom of the screen:

// Note this is all React code wrapped in UseEffects

// Grabbing references to the current color and the meta tag
metaTag.current = document.querySelector('meta[name="theme-color"]');
currThemeColor.current = metaTag.current?.getAttribute('content') ?? null;

// ....

// The top-of-screen intersection observer only sets the theme-color
const observerTop = new IntersectionObserver(
(es) => {
if (!metaTag.current) return;
const selectedEntry = es.filter((e) => e.isIntersecting);
const target = selectedEntry[0]?.target;
if (!target) return;
const color = target.getAttribute('data-metathemeswap-color');
if (!color) return;
currThemeColor.current = color;
metaTag.current.setAttribute('content', currThemeColor.current);
},
{
// Detect intersections at the very top of the screen
rootMargin: '-0.05% 0px -99.9% 0px',
},
);

// The bottom-of-screen intersection observer sets the background color
// and also updates the meta theme-color for one animation frame.
const observerBottom = new IntersectionObserver(
(es) => {
if (!metaTag.current) return;
const selectedEntry = es.filter((e) => e.isIntersecting);
const target = selectedEntry[0]?.target;
if (!target) return;
const color = target.getAttribute('data-metathemeswap-color');
if (!color) return;
document.body.style.backgroundColor = color;
metaTag.current.setAttribute('content', currThemeColor.current + 'fe');
const meta = metaTag.current;
requestAnimationFrame(() => {
meta.setAttribute('content', currThemeColor.current || '');
});
},
{
// Detect intersections at the very bottom of the screen
rootMargin: '-99.9% 0px -0.05% 0px',
},
);

Now we can observe whatever DOM element we desire to trigger color changes with a client-side hook and a ref:

export default function useMetaTheme(ref: RefObject<Element>, color: string) {
const { observerTop, observerBottom } = useContext(MetaThemeContext);
useEffect(() => {
const node = ref.current;
if (!node) return;
node.setAttribute('data-metathemeswap-color', color);
}, [color, ref]);

useEffect(() => {
const node = ref.current;
if (!node || !observerTop) return;
observerTop?.observe(node);
return () => observerTop?.unobserve(node);
}, [observerTop, ref]);

useEffect(() => {
const node = ref.current;
if (!node || !observerBottom) return;
observerBottom?.observe(node);
return () => observerBottom?.unobserve(node);
}, [observerBottom, ref]);
}

For example, using the above implementation, here’s a small demo component that synchronizes whichever bar it overlaps with:

function ColoredSection({ color }: { color: string }) {
const ref = useRef<HTMLDivElement>(null);
useMetaTheme(ref, color);
return (
<div id={color} className="ColorSection" style={{ backgroundColor: color }} ref={ref}>
{color}
</div>
)
}

For the full code, check out the work-in-progress NPM package meta-theme-swap I’ve published to make this easier for React projects (vanilla implementation coming soon):

Pay close attention to the navigation bar’s updating code. We opt for triggering a repaint of the status bar by appending “fe” to the end of its hex code, and removing the “fe” on the next animation frame (to fully preserve visual appearance, as this only nudges the alpha value a bit more transparent, which is enough to be a “new color”). In this way, we control both the status bar and the navigation bar independently:

A demo site for independently controlling the minimized navigation bar and status bar

And implemented back on my main website:

That’s pretty much it! A fully unified, color-optimized design.

Conclusions

It’s likely that most websites will have no need for this complex of an implementation. For example, the traditional web interface with a sticky navbar at the top of the screen looks generally great just by setting the theme-color statically, as Quora does:

Quora uses a static `theme-color` and white `background-color`, which looks great.

I’d also like to add that the theme-color native effect also can be seen in the desktop Safari browser, but only in light mode and in the compact tab view:

And, in the end, I opted to choose the smooth CSS transitions styling both the status bar and the navigation bar with the background-color of the body, instead of using the split display. I may attempt to implement a tweening mechanism in the future to try to replicate the CSS transition for thetheme-color, but for now it’s good.

Hopefully you found this useful! For a more comprehensive writeup on the theme-color meta tag specifically, check out the amazing article below.

--

--