API design principles for UI components

Best practices for designing developer interface components APIs, useful for UI components coding and system design interviews

User Interface component libraries like Bootstrap and Material UI help developers build UI faster by providing commonly used components like buttons, tabs, modals, etc so that developers do not have to reinvent the wheel by building these components from scratch whenever they start on a new project.

Often during front end interviews, you will be asked to build UI components and design an API to initialize them. Designing good component APIs is bread and butter for Front End Engineers. This page covers some of the top tips and best practices for designing UI component APIs. Some of these tips may be framework-specific but can be generalized for other component-based UI frameworks.

Initialization

jQuery-style

Before modern JavaScript UI libraries/frameworks like React, Angular, and Vue came into the picture, jQuery (and jQuery UI) was the most popular way to build UI. jQuery UI popularized the idea of initializing UI components via "constructors" which involved two arguments:

  1. Root Element: A root DOM element to render the contents.
  2. Customization Options: Optional, additional, customization options usually in the form of a plain JavaScript object.

Using jQuery UI, one can turn a DOM element into a slider (among many other UI components) with a single line of code:

<div id="gfe-slider"></div>
<script>
$('#gfe-slider').slider();
</script>

jQuery refresher: jQuery UI's slider() method (constructor) takes in a JavaScript object which serves as customization options. Doing $('#slider') selects the <div id="slider"> element and returns a jQuery object that contains convenient methods to "do something" with the element such as addClass, removeClass, etc and other DOM manipulation methods. Within jQuery methods, the selected element can be accessed via the this keyword. jQuery APIs are built around this "select an element and do something with it" approach, hence the slider() method does not need an argument for the root DOM element.

The slider can be customized by passing in a plain JavaScript object of options:

<div id="gfe-slider"></div>
<script>
$('#gfe-slider').slider({
animate: true,
max: 50,
min: 10,
// See other options here: https://api.jqueryui.com/slider/
});
</script>

Vanilla JavaScript Style

There's no vanilla JavaScript style for initializing components since vanilla JavaScript is not a standard or framework. But if you have read enough of GreatFrontEnd's solutions for our vanilla JavaScript UI coding questions, you'll see that the API we recommend is similar to jQuery's, the constructor takes in a root element and options:

function slider(rootEl, options) {
// Do something with rootEl and options.
}

React

React forces you to write UI as components which contain its own logic and appearance. React components are JavaScript functions that return markup (a description of how to render itself). React components can take in props, which are essentially customization of a component's options.

function Slider({ min, max }) {
// Use the props to render a customized component.
return <div>...</div>;
}
<Slider max={50} min={10} />;

Components do not take in a root element. To render the element into the page, a separate API is used.

import { createRoot } from 'react-dom/client';
import Slider from './Slider';
const domNode = document.getElementById('#gfe-slider');
// React will manage the DOM within this element.
const root = createRoot(domNode);
// Display the Slider component within the element.
root.render(<Slider max={50} min={10} />);

You will usually not need to call createRoot() yourself if the entire page a React app because there will only be one createRoot call for the root/page-level component.

Customizing Appearance

Even though UI components in UI libraries provide default styling, developers will usually want to customize them with their company/product's branding and theme colors. Hence all UI components will allow for customization of the appearance, via a few methods:

Class Injection

The idea here is simple, components accept a prop/option to allow the developer to provide their own classes and these classes are added to the actual DOM elements. This approach is not very robust because if the component also adds its own styling via classes, there could be conflicting properties within the component's classes and developer-provided classes.

React

import clsx from 'clsx';
function Slider({ className, value }) {
return (
<div className={clsx('gfe-slider', className)}>
<input type="range" value={value} />
</div>
);
}
<Slider className="my-custom-slider" value={50} />;
/* UI library default stylesheet */
.gfe-slider {
height: 12px;
}
/* Developer's custom stylesheet */
.my-custom-slider {
color: red;
}

Through class injection, developers can change the text color of the component to be red.

If there are many DOM elements within the component to be targeted and one single className prop is not sufficient, you can also have multiple differently-named props for classNames of different elements:

import { useId } from 'react';
import clsx from 'clsx';
function Slider({ label, value, className, classNameLabel, classNameTrack }) {
const id = useId();
return (
<div className={clsx('gfe-slider', className)}>
<label className={clsx('gfe-slider-label', classNameLabel)} for={id}>
{label}
</label>
<input
className={clsx('gfe-slider-range', classNameRange)}
id={id}
type="range"
value={value}
/>
</div>
);
}

jQuery

In jQuery, classes can also be passed as a field on the options.

$('#gfe-slider').slider({
// In reality, jQuery UI takes in a `classes` field instead
// since there are multiple elements.
class: 'my-custom-slider',
});

In reality, all of jQuery UI's component initializers take in the classes field to allow adding additional classes to individual elements. The following example is taken from jQuery UI Slider:

$('#gfe-slider').slider({
classes: {
'ui-slider': 'highlight',
'ui-slider-handle': 'ui-corner-all',
'ui-slider-range': 'ui-corner-all ui-widget-header',
},
});

Non-deterministic Styling

Class injection has an unobvious downside — the final visual result is non-deterministic and may not be what is expected. Take the following code for example:

import clsx from 'clsx';
function Slider({ className, value }) {
return (
<div className={clsx('gfe-slider', className)}>
<input type="range" value={value} />
</div>
);
}
<Slider className="my-custom-slider" value={50} />;
/* UI library default stylesheet */
.gfe-slider {
height: 12px;
color: black;
}
/* Developer's custom stylesheet */
.my-custom-slider {
color: red; /* .gfe-slider also defines a value for color. */
}

In the example above, both .gfe-slider and .my-custom-slider classes specify the color and because these two selectors have the same specificity, the winning style is actually the class that appears later on the HTML page. If the loading order of the stylesheet is not guaranteed (e.g. if stylesheets are lazily loaded), the visual result will not be deterministic. This is when developers start using hacks like !important or .my-custom-slider.my-custom-slider to let their selectors win the specificity war and the CSS code starts becoming unmaintainable.

In jQuery UI, if a custom class is added, the existing default value is not used. This removes the "winning style" ambiguity but the user must now reimplement all the necessary styles present in the original class. This approach can also be applied to React components to resolve the ambiguity.

Despite its possible flaws, class injection is still a very popular option.

CSS Selector Hooks

Technically speaking, developers can achieve customization if they read the source code of the component and define their custom styling by using the same classes. However, doing this is dangerous as relying on a component's internals and there's no guarantee that the class names won't change in future.

If UI library authors can make these classes/attributes part of their API, which comes with these guarantees:

  1. The list of selectors is published for external reference.
  2. Existing published selectors will not be changed. If they are changed, it will be a breaking change and a version bump is needed as per semver.

Then it's an acceptable practice and developers can "hook" onto them (target them) by using these selectors in their stylesheets.

An example of hooking into a component's selectors:

import { useId } from 'react';
import clsx from 'clsx';
function Slider({ label, value }) {
const id = useId();
return (
<div className="gfe-slider">
<label className="gfe-slider-label" for={id}>
{label}
</label>
<input className="gfe-slider-range" id={id} type="range" value={value} />
</div>
);
}
/* UI library default stylesheet */
.gfe-slider {
font-size: 12px;
}
/* No other classes are defined in this stylesheet,
gfe-slider-label and gfe-slider-range are added
to the component just for developers to gain access
to the underlying elements. */
/* Developer's custom stylesheet */
.gfe-slider {
font-size: 16px; /* Conflicts with the default .gfe-slider */
padding: 10px 20px;
}
.gfe-slider-label {
color: red;
}
.gfe-slider-range {
height: 20px;
}

This approach saves developers the hassle of passing in classes into the component as they only have to write CSS to customize the styling. Reach UI, a headless UI component library for React, uses element selectors. Each component has a data-reach-* attribute on the underlying DOM element.

[data-reach-menu-item] {
color: blue;
}

However, this approach still suffers from the non-deterministic styling issue as per "class injection" and doesn't easily allow per-instance styling. If per-instance styling is desired, this approach can be combined with the class injection approach.

Theme Object

Instead of taking in classes, the component takes in an object of key/values for styling. This is useful if there is only a strict subset of properties to customize, or if you want to restrict styling to only a few properties.

const defaultTheme = { color: 'black', height: 12 };
function Slider({ value, label, theme }) {
// Combine with default.
const finalTheme = { ...defaultTheme, ...theme };
return (
<div className="gfe-slider">
<label
for={id}
style={{
color: finalTheme.color,
}}>
{label}
</label>
<input
id={id}
type="range"
value={value}
style={{
height: finalTheme.height,
}}
/>
</div>
);
}
<Slider themeOptions={{ color: 'red', height: 24 }} {...props} />;

However, since no classes with conflicting styles are used, and inline styles have higher specificity than classes, there's no specificity conflict and the inline styles will win. However, the number of options that need to be supported can grow really quickly. Inline styles are also present in the DOM per component instance, which can be bad for performance if this component is rendered hundreds/thousands of times within a page.

The theme object is just a way to restrict the styling to certain properties and optionally, an accepted set of values, the values do not need be used as inline styles and instead can be combined with other styling approaches.

CSS Preprocessor Compilation

UI libraries are usually written with CSS preprocessors like Sass and Less. Bootstrap is written with Sass and they provide a way to customize the Sass variables used so that developers can generate a custom UI library stylesheet.

This approach is great because it doesn't rely on overriding CSS selectors to achieve customization. There's also less amount of resulting CSS and no redundant overridden styles. The downside is that a compilation step is needed.

CSS Variables / Custom Properties

CSS variables (or more formally known as CSS custom properties) are entities defined by CSS authors that contain specific values to be reused throughout a document. The var() function, it accepts fallback values if the given variable is not set.

function Slider({ value, label }) {
return (
<div className="gfe-slider">
<label for={id}>{label}</label>
<input id={id} type="range" value={value} />
</div>
);
}
/* UI library default stylesheet */
.gfe-slider {
/* Fallback of 12px if not set. */
font-size: var(--gfe-slider-font-size, 12px);
}
/* Developer's custom stylesheet */
:root {
--gfe-slider-font-size: 15px;
}

The developer can define a value for --gfe-slider-font-size globally via the :root selector and set the font size for the .gfe-slider class to be 15px. The benefit of this approach is that it doesn't require JavaScript, however, per-component customization will be more troublesome (but still possible).

Render Props

In React, render props are function props that a component uses to know what to render. It is useful for separating behavior from presentation. Many behavioral/headless UI libraries like Radix, Headless UI, and Reach UI make heavy use of render props.

Internationalization (i18n)

Does your UI work for multiple languages? How easy is it to add support for more languages?

Avoid hardcoding of labels in a certain language

Some UI components have label strings within them (e.g. image carousel has labels for prev/next buttons). It'd be good to allow customization of these label strings by making them part of component props/options.

Right-to-left languages

Some languages (e.g. Arabic, Hebrew) are read from right-to-left and the UI has to be flipped horizontally. The component can take in a direction prop/option and change the order of how elements are rendered. For example, the prev and next buttons will be on the right and left respectively in an RTL language.

Use CSS logical properties to futureproof your styles and let your layout work for different writing modes.

Mark complete