Building a Reusable List with Emotion

During some recent refactoring at Sentry, I found that we lacked a generic List component usable across projects and features. So I started to build one. Sentry’s styling stack is built on Emotion, which I had only passing experience with. It’s a CSS-in-JS solution aimed at predictable style composition and strong DX, supporting both string and object styles.

The fundamental problem Emotion solves in large codebases is CSS class collisions. If two unrelated components both define an .active class, maintaining which style applies becomes troublesome. Emotion generates unique class names—like .oefioaueg—so styles stay scoped to a single component. Tools like CSS Modules take a similar approach.

<div class="css-1tfy8g7-List e13k4qzl9"></div>

Setup for Emotion depends entirely on your build environment, but once installed, the most basic list component definition combines markup and style in one declaration. This collocation of HTML and CSS feels unusual at first, but it keeps related code in one place:

import React from 'react';
import styled from '@emotion/styled';

export const List = styled('ul')`
  list-style: none;
  padding: 0;
`;

Importing <List> in another component gives you a styled <ul> with the generated class attached automatically.

import List from 'components/list';

<List>This is a list item.</List>

The component needed to support both unordered and ordered lists, as well as an icon variant within each list item. Rather than building custom prop logic to switch elements, Emotion’s as attribute lets us render the same component as different HTML elements:

<List>This will render a ul.</List>
<List as="ol">This will render an ol.</List>

This avoids branching inside the component, which keeps the code simple.

I sketched out the ideal API for the component—two main components, <List> and <ListItem>, with the latter able to nest an icon subelement. Here’s the foundation:

<List>
  <ListItem>Item 1</ListItem>
  <ListItem>Item 2</ListItem>
  <ListItem>Item 3</ListItem>
</List>

<List>
  <ListItem icon={<IconBusiness color="orange400" size="sm" />}>Item 1</ListItem>
  <ListItem icon={<IconBusiness color="orange400" size="sm" />}>Item 2</ListItem>
  <ListItem icon={<IconBusiness color="orange400" size="sm" />}>Item 3</ListItem>
</List>

<List as="ol">
  <ListItem>Item 1</ListItem>
  <ListItem>Item 2</ListItem>
  <ListItem>Item 3</ListItem>
</List>
import React from 'react';
import styled from '@emotion/styled';

export const List = styled('ul')`
  list-style: none;
  padding: 0;
  margin-bottom: 20px;

  ol& {
    counter-reset: numberedList;
  }
`;

The ol& selector targets the ordered-list variant while other styles handle the list’s base appearance. Adding a temporary background color to these selectors is a useful sanity check during development.

At Sentry we use TypeScript, so we define props before laying out any subcomponents. The ListItem props include optional custom styling and an icon that we may want to align within the list item.

type ListItemProps = {
  icon?: React.ReactNode;
  children?: string | React.ReactNode;
  className?: string;
};

Some items need a small, styled icon next to the text. The IconWrapper handles that mapping by wrapping a preexisting icon—like IconBusiness—within a span, so we can control its sizing and alignment:

<List>
  <ListItem icon={<IconBusiness color="orange400" size="sm" />}>Item 1</ListItem>
  <ListItem icon={<IconBusiness color="orange400" size="sm" />}>Item 2</ListItem>
  <ListItem icon={<IconBusiness color="orange400" size="sm" />}>Item 3</ListItem>
</List>
type ListItemProps = {
  icon?: React.ReactNode;
  children?: string | React.ReactNode;
  className?: string;
};

const IconWrapper = styled('span')`
  display: flex;
  margin-right: 15px;
  height: 16px;
  align-items: center;
`;

With those pieces defined, ListItem itself brings them together: it conditionally renders the wrapper and the icon when the icon prop is present, while managing all the style variants:

export const ListItem = styled(({icon, className, children}: ListItemProps) => (
  <li className={className}>
    {icon && (
      <IconWrapper>
        {icon}
      </IconWrapper>
    )}
    {children}
  </li>
))<ListItemProps>`
  display: flex;
  align-items: center;
  position: relative;
  padding-left: 34px;
  margin-bottom: 20px;
	
  /* Tiny circle and icon positioning */
  &:before,
	& > ${IconWrapper} {
    position: absolute;
    left: 0;
  }

  ul & {
    color: #aaa;
    /* This pseudo is the tiny circle for ul items */ 
    &:before {
      content: '';
      width: 6px;
      height: 6px;
      border-radius: 50%;
      margin-right: 15px;
      border: 1px solid #aaa;
      background-color: transparent;
      left: 5px;
      top: 10px;
    }
		
    /* Icon styles */
    ${p =>
      p.icon &&
      `
      span {
        top: 4px;
      }
      /* Removes tiny circle pseudo if icon is present */
      &:before {
        content: none;
      }
    `}
  }
  /* When the list is rendered as an <ol> */
  ol & {
    &:before {
      counter-increment: numberedList;
      content: counter(numberedList);
      top: 3px;
      display: flex;
      align-items: center;
      justify-content: center;
      text-align: center;
      width: 18px;
      height: 18px;
      font-size: 10px;
      font-weight: 600;
      border: 1px solid #aaa;
      border-radius: 50%;
      background-color: transparent;
      margin-right: 20px;
    }
  }
`;

The result is a relatively compact <List>/<ListItem> pair built with Emotion and TypeScript. That said, I’m still undecided on the syntax. It makes trivial components very simple, but medium-sized ones feel more complex than they should be—possibly intimidating for newcomers. Still, working through this component taught me several useful patterns for mixing TypeScript, React, and maintainable CSS.