back

Web components in VS Code

December 17, 2023 – @hawkticehurst

Welcome back to 7 Days of Web Components! A series of interactive blog posts that introduces and explores the world of web components.

This is the post for day five, if you missed the previous posts I encourage you to go back to day one and start there.

Today we’re talking about an IRL (in real life) open-source component library built with web components. It also happens to be a component library that I built and maintain as part of my day job at Microsoft –– it’s the Webview UI Toolkit for VS Code.

In this post, we’re going to talk through some specific examples of how this library is built and some other thoughts and musings.

Please note: This blog post includes interactive code examples. If you’re reading this from an RSS feed, I highly recommend you open the original post.

The toolkit

The Webview UI Toolkit is an open-source library of web components that implement the VS Code design system. These components are made available to VS Code extension authors to help them build user interfaces for their extensions (specifically webview-based extensions) that have the same look and feel as the rest of the code editor.

The library has been built and maintained since early 2021 and is now used in extensions like GitHub Copilot, GitKraken, Red Hat Java Language Support, Nx Console, Azure Bicep, and many other projects.

The components are built using a web component framework built by Microsoft called FAST. The toolkit specifically takes advantage of FAST Foundation which is a set of unstyled base components that the FAST team builds and maintains.

Click me!

Why web components?

In VS Code, webview extensions are a powerful way to add custom functionality beyond what the extension API supports. Under the hood, webviews are implemented using an iframe, which means a webview extension is effectively an isolated webpage inside of VS Code where extension authors can build whatever they want using any technology they desire.

Historically, this meant that the responsibility of developing UI which aligns with the VS Code design language and follows the webview guidelines fell to extension authors. A big goal of the toolkit was to shift some of this responsibility away from extension developers by providing a set of core components that make it easier to build higher-quality webview UIs in VS Code.

Additionally, since webviews operate as environments isolated from the usual resources and APIs available to VS Code, it means the native VS Code UI could not be used.

So translating that into a set of requirements, we’re looking for a technology that can:

Web components are a perfect candidate for this job. They are standalone and composable HTML elements that can be used with any* tech stack and when used with shadow DOM offer a very high degree of control and isolation from other code that may exist.

*React is the notable odd duck out here.. we’ll talk about this later.

Theming

One of the most important and interesting early tasks of this component library was integrating with VS Code’s theming system.

VS Code has a theme service that exposes somewhere around 500+ color tokens that correspond to different parts of the user interface. Themes are defined as huge JSON files that individually set color values for each token. Below is an example from the default dark theme in VS Code:

{
	"$schema": "vscode://schemas/color-theme",
	"type": "dark",
	"colors": {
		"button.background": "#0078d4",
		"button.foreground": "#ffffff",
		// ... other tokens ...
	},
	// ... other configurations ...
}

When a person switches to a new theme VS Code will, under the hood, update all the token values with the color values defined in the JSON file associated with the newly selected theme.

Native VS Code UI will use a system of internal TypeScript objects/variables to access these color tokens, but code inside a webview cannot do this since it is running in an iframe isolated from the usual APIs and resources available in VS Code.

Thankfully, VS Code also exposes all of its theme colors as CSS variables attached to the <html> element of each webview. When there’s a theme change, every CSS variable will be updated with the new color values.

<html lang="en" style="--vscode-button-foreground: #ffffff; --vscode-button-background: #0078d4; ... many other tokens ...">

Listening for theme changes

While the simplest way to inherit these theme changes would be to directly use the exposed CSS variables in the toolkit components, FAST has first-class support for Design Tokens which provide a powerful and typesafe abstraction to represent values in a design system (like color, font, spacing, and so on). They also allow for the flexibility to handle some edge cases that show up later when correctly applying VS Code themes.

Since the toolkit uses these tokens, we need to find another way to listen for theme changes. We can accomplish this a MutationObserver.

A MutationObserver is a web API that allows you to watch for changes that happen to the DOM. Since we know that a bunch of CSS variables are added to the DOM and updated each time a theme change occurs we can use an observer to watch for that change.

Pulled directly from the toolkit source code, here’s the function that will initialize a theme change listener for the toolkit components. As added context, this function is called one time when the toolkit is initially loaded onto the page (even more specifically, initialization happens when the first toolkit design token is created).

export function initThemeChangeListener(
	tokenMappings: Map<string, CSSDesignToken<T>>
) {
	// Wait until DOM loads to create observer
	window.addEventListener('load', () => {
		// Create new observer that will call the applyCurrentTheme 
		// function when a change occurs
		const observer = new MutationObserver(() => {
			applyCurrentTheme(tokenMappings);
		});
		// Configure observer to watch for changes on the class 
		// attribute of the <body> element
		observer.observe(document.body, {
			attributes: true,
			attributeFilter: ['class'],
		});
		// Apply themes for the first time since the DOM already exists 
		// and the observer code will not until the next theme change
		applyCurrentTheme(tokenMappings);
	});
}

We notably are listening for changes on the <body> class attribute instead of the <html> element because VS Code conveniently sets/resets a class name representing what type of theme has been applied when the theme changes.

<body class="vscode-dark empty" role="document" data-vscode-theme-kind="vscode-dark" data-vscode-theme-name="Dark Modern" data-vscode-theme-id="Default Dark Modern">

If we move onto the all-important applyCurrentTheme function, this is where all the values of each CSS variable set on the <html> element are applied to the design tokens created with FAST.

function applyCurrentTheme(tokenMappings: Map<string, CSSDesignToken<T>>) {
	// Get a reference to the body element
	const body = document.querySelector('body');
	// Get the computed styles of the body element
	const styles = getComputedStyle(body);

	if (body) {
		// Get the name of the theme type
		const themeKind = body.getAttribute('data-vscode-theme-kind');
		// For every VS Code design token...
		for (const [vscodeTokenName, toolkitToken] of tokenMappings) {
			// Get the value of a given VS Code theme CSS variable
			let value = styles.getPropertyValue(vscodeTokenName).toString();

			// Handle a couple of styling edge cases when a high contrast 
			// theme is applied
			if (themeKind === 'vscode-high-contrast') {
				// There are a handful of VS Code theme tokens that have no 
				// value when a high contrast theme is applied.
				//
				// This is an issue because when no value is set the toolkit 
				// tokens will fall back to their default color values (aka 
				// the VS Code dark theme color palette). This results in the 
				// backgrounds of a couple of components having default dark
				// theme colors––thus breaking the high contrast theme.
				//
				// The below code, catches these tokens which have no value 
				// and are also background tokens, then overrides their value 
				// to be transparent.
				if (
					value.length === 0 &&
					toolkitToken.name.includes('background')
				) {
					value = 'transparent';
				}
				// Set icon button hover to be transparent in high contrast 
				// themes
				if (toolkitToken.name === 'button-icon-hover-background') {
					value = 'transparent';
				}
			} else if (themeKind === 'vscode-high-contrast-light') {
				if (
					value.length === 0 &&
					toolkitToken.name.includes('background')
				) {
					// Set button variant hover backgrounds to correct values 
					// based on VS Code core source
					switch (toolkitToken.name) {
						case 'button-primary-hover-background':
							value = '#0F4A85';
							break;
						case 'button-secondary-hover-background':
							value = 'transparent';
							break;
						case 'button-icon-hover-background':
							value = 'transparent';
							break;
					}
				}
			} else {
				// Set contrast-active-border token to be transparent in 
				// non-high-contrast themes
				if (toolkitToken.name === 'contrast-active-border') {
					value = 'transparent';
				}
			}
			// Set a given toolkit token to contain the corresponding 
			// CSS value of the new theme
			toolkitToken.setValueFor(body, value);
		}
	}
}

Similar to the observer, we focus on the <body> element because all the CSS variables will be inherited from the <html> element and, more importantly, a custom data attribute exists that provides the name of the type of theme that is currently applied (i.e. vscode-dark, vscode-light, etc.). This information is used to correct a handful of styling edge cases and issues in different themes.

Pulling this all together, it means that we can create and use a single set of design tokens that automatically listen for and correctly apply theme changes in VS Code. The below code snippet shows a brief look at FAST’s method of defining styles that is be passed into a component definition (in a different file) and automatically applied to the component shadow DOM.

import { css } from '@microsoft/fast-element';
import { buttonPrimaryBackground, buttonPrimaryForeground } from '../design-tokens.js';

const BaseButtonStyles = css`
	:host {
		color: ${buttonPrimaryForeground};
		background: ${buttonPrimaryBackground};
		/* ... other styles ... */
	}
`;

This was an admittedly quick breeze through the topic of handling theming in the Webview UI Toolkit that leaves out a tonnn of extra context and tiny details, so apologies for any potential confusion. If you’re curious and want to know more, feel free to ask me questions on Mastodon and I’d be happy to answer and/or write a follow-up post at a later time.

I hope this did at least provide a general sense of what it looks like to solve a real-life problem in the context of a web component library.

Integrating with web frameworks

Another interesting task that I’ve spent a fair bit of time working on is making sure that the toolkit integrates well with web frameworks. It’s very common to see webview extensions built using frontend web frameworks (especially of the single-page app variety) like React, Vue, and Svelte.

It’s a choice that makes a lot of sense in this context. VS Code extensions are precompiled and downloaded by developers in the same way you might install an iOS or Android application. This means none of the usual web application concerns like initial load time and other network-based metrics apply here. Furthermore, creating multiple webviews to render different pages can be a huge pain, so a single-page app that can render multiple views within a single page is desirable.

In most web frameworks web components are natively supported and there isn’t a whole lot of work to do except document any special configurations that are needed and provide good sample code for extension authors to reference.

One notable exception

React is the only remaining major web framework today that does not (as of writing this) natively support web components. The two big issues are:

Thankfully, there is a plan to natively support custom elements in React v19, but it means the toolkit has to do some extra work to grapple with the current reality.

A word of warning

When the toolkit was initially released as a public beta nothing was implemented yet to support React developers and we started getting a lot of requests and issue reports asking for guidance and support.

The usual workaround to use web components in React is to wrap those components in a React component and then manually handle cases when rich data is passed to the components or when a DOM event is dispatched. Tools like FAST provide a utility package for doing this automatically, so the initial recommendation was for developers to use that.

This proved to be cumbersome and error-prone for a variety of reasons, so there reached a point where the GitHub Next team (the team behind GitHub Copilot and a predominantly React-based team) graciously volunteered to contribute a pull request that would wrap and export the toolkit components as React components.

The effort was successful and well-received, but I have one huge regret when I look back.

When this was built I had recently learned about package exports and I thought it would be an elegant and cute solution if developers could install the React components within the same package, but just at a different export:

// Regular web component import
import { vsCodeButton } from '@vscode/webview-ui-toolkit';

// React component import
import { VSCodeButton } from '@vscode/webview-ui-toolkit/react';

This is what we did and while not a catastrophic decision it has led to many headaches during the past two years. By combining everything in the same package, it has:

It’s been a decision filled with a lot of wonderful (and at times a bit embarrassing) learning lessons and the next version of the toolkit will finally be separating these components into two separate packages.

But if I had to summarize these lessons in a shorter way, my parting words of wisdom to you are:

Never be cute. Always do the simple and straightforward thing. You’ll thank yourself later.

In the next episode…

Thanks for stopping by! I hope this was an enjoyable (albeit brief) look at a web component library in the wild. Like I said above, if you have any questions feel free to reach out.

Follow me on Mastodon for updates and see you in the next one!


Next post: Web components reading list