Type something to search...
How to Style Lists and Bullets with Modern CSS?

How to Style Lists and Bullets with Modern CSS?

A designer hands you a mockup where every bullet is a small brand-colored dash, the numbered steps use large bold numerals in a circle, and the list items align perfectly with the paragraph text above them. For years the standard answer was list-style: none plus a ::before pseudo-element positioned absolutely, with a few magic numbers to line everything up. It worked, but it was fragile, and it quietly removed list semantics for some screen reader users.

Modern CSS gives you better tools. This article walks through ::marker, string and image markers, @counter-style, CSS counters, alignment and spacing, and how to keep your lists accessible when you restyle them.

How Lists Are Rendered

Every li element inside a ul or ol has display: list-item. That display type generates a marker box, which holds the bullet or number, and a principal box, which holds the content. The marker's appearance comes from three properties:

PropertyControlsExample values
list-style-typeThe bullet shape or numbering systemdisc, circle, square, decimal, "– "
list-style-positionWhether the marker sits outside or insideoutside (default), inside
list-style-imageAn image used in place of the bulleturl("/icons/check.svg")

The list-style shorthand sets all three. The browser's default stylesheet also gives lists padding-inline-start: 40px and vertical margins of 1em, which is where most alignment headaches start.

Fixing Default Spacing and Alignment

The 40px default indent rarely matches your type scale. Set it in em or rem so it scales with the text:

ul,
ol {
  padding-inline-start: 1.5em;
  margin-block: 0 1.25em;
}

li + li {
  margin-block-start: 0.5em;
}

li > ul,
li > ol {
  margin-block: 0.5em 0;
}

Use logical properties like padding-inline-start instead of padding-left. In a right-to-left layout the indent then moves to the correct side automatically.

Outside vs. Inside Markers

With list-style-position: outside, the marker hangs in the padding and wrapped lines align with the first line of text. With inside, the marker becomes part of the first line, so wrapped lines tuck under the marker. For body copy, keep outside. It produces the clean hanging indent readers expect, and it is the traditional typographic choice.

If you want bullets to align flush with the paragraph edge above them, a common editorial style, keep the markers outside and set the indent so the marker sits in the margin:

.prose ul {
  padding-inline-start: 1.25em;
  margin-inline-start: 0;
}

The text then lines up with surrounding paragraphs and the bullets hang just to the left, which works well inside the generous gutters of a long-form article layout.

Styling Markers with ::marker

The ::marker pseudo-element targets the marker box directly. It is supported in all current browsers and is the cleanest way to recolor or resize bullets without touching the content.

li::marker {
  color: var(--color-primary);
  font-weight: 700;
}

ol li::marker {
  font-variant-numeric: tabular-nums;
}

::marker accepts only a limited set of properties:

  • All font properties (font-size, font-weight, font-family, font-variant-numeric, and so on)
  • color
  • content
  • white-space, direction, unicode-bidi, text-combine-upright
  • animation and transition properties

You cannot set margin, padding, background, or width on a marker. If your design needs a circled number or a filled badge behind the bullet, you need a pseudo-element approach (covered below).

Tabular numerals are worth turning on for ordered lists. They keep "9." and "10." the same width so list content stays aligned, a technique covered in more depth in our post on tabular figures.

Changing the Marker Content

You can set any string as the bullet directly in list-style-type:

ul.dashes {
  list-style-type: "– ";
}

ul.arrows {
  list-style-type: "→ ";
}

ul.checks li::marker {
  content: "✓ ";
  color: green;
}

The string form in list-style-type has wide support. Using content on ::marker works in Chrome and Firefox, while Safari support has historically lagged, so prefer the list-style-type string when you only need a different character. Include a trailing space in the string, or the marker will butt against the text.

Image and SVG Bullets

list-style-image accepts a URL, but you cannot size or position the image, so it only looks right if the file is drawn at the exact size you need. A more flexible approach uses a mask on a pseudo-element, which also lets the icon inherit the text color:

ul.icon-list {
  list-style: none;
  padding-inline-start: 0;
}

ul.icon-list li {
  position: relative;
  padding-inline-start: 1.75em;
}

ul.icon-list li::before {
  content: "";
  position: absolute;
  inset-inline-start: 0;
  top: 0.3em;
  width: 1em;
  height: 1em;
  background-color: currentColor;
  mask: url("/icons/check.svg") no-repeat center / contain;
}

Using em for size and offset keeps the icon proportional to the text and aligned with the first line's x-height across font sizes. Adjust the top value per typeface, since fonts with a tall x-height sit slightly differently.

Custom Numbering with @counter-style

The @counter-style at-rule defines your own numbering systems that work anywhere list-style-type does. It is supported in Chrome, Firefox, and Safari 17 and later.

@counter-style circled {
  system: fixed;
  symbols: "①" "②" "③" "④" "⑤" "⑥" "⑦" "⑧" "⑨" "⑩";
  suffix: " ";
  fallback: decimal;
}

@counter-style thumbs {
  system: cyclic;
  symbols: "👉";
  suffix: " ";
}

@counter-style padded-decimal {
  system: extends decimal;
  pad: 2 "0";
  suffix: ". ";
}

ol.steps {
  list-style-type: padded-decimal;
}

The padded-decimal style renders "01.", "02.", and so on, which is a common look for step lists. The fallback descriptor tells the browser what to use once the fixed symbols run out.

Useful system values:

  1. cyclic loops through the symbols repeatedly. Good for bullets.
  2. fixed uses each symbol once, then falls back.
  3. numeric treats the symbols as digits in a positional system.
  4. alphabetic works like lower-alpha with your own symbols.
  5. additive builds numbers by adding symbol values, like Roman numerals.
  6. extends reuses an existing style and changes only some descriptors.

Numbering with CSS Counters

When you need numbers styled with backgrounds, borders, or exact positioning, combine CSS counters with ::before. This is the right tool for circled step numbers.

ol.steps {
  list-style: none;
  counter-reset: step;
  padding-inline-start: 0;
}

ol.steps > li {
  counter-increment: step;
  position: relative;
  padding-inline-start: 3em;
  min-height: 2em;
  margin-block-end: 1em;
}

ol.steps > li::before {
  content: counter(step);
  position: absolute;
  inset-inline-start: 0;
  top: 0;
  width: 2em;
  height: 2em;
  display: grid;
  place-items: center;
  border-radius: 50%;
  background: var(--color-primary);
  color: white;
  font-weight: 700;
  font-variant-numeric: tabular-nums;
}

Counters also handle nested outline numbering such as "2.1" and "2.1.3" with the counters() function:

ol.outline {
  counter-reset: item;
  list-style: none;
}

ol.outline > li {
  counter-increment: item;
}

ol.outline > li::before {
  content: counters(item, ".") " ";
  font-weight: 600;
}

The start attribute on ol and the value attribute on li still work with native markers. If you replace the markers with a counter, set the starting value with counter-reset: step 4 instead, since the counter ignores those HTML attributes.

Keep Lists Accessible

Screen readers announce lists ("list, 5 items") which helps users skim and skip. Restyling must not remove that.

The Safari caveat. WebKit removes list semantics from a ul or ol that has list-style: none when VoiceOver is in use. This was a deliberate choice to stop navigation menus being announced as lists. If your styled list is real content, restore the semantics explicitly:

<ul class="icon-list" role="list">
  <li>Free shipping over $50</li>
  <li>30-day returns</li>
  <li>Two-year warranty</li>
</ul>

Other rules worth following:

  • Use ul for unordered items and ol when order matters. Do not fake lists with paragraphs and line breaks.
  • Decorative markers created with ::before and content can be read aloud by some screen readers. For pure decoration, use an empty string for content with a background or mask, as in the icon example above, or provide alternative text with the content: "★" / "" syntax.
  • Keep markers at sufficient contrast. A light-gray bullet is decorative, but numbers in ordered lists carry meaning and should meet the 4.5:1 ratio from WCAG contrast requirements.
  • Respect user text spacing. WCAG 1.4.12 requires that content still work when users set line height to 1.5 and paragraph spacing to 2× the font size. Avoid fixed heights on list items, which is why the step example uses min-height.

A Complete Prose List Style

Here is a compact set of rules you can drop into an article stylesheet:

.prose :is(ul, ol) {
  padding-inline-start: 1.5em;
  margin-block: 0 1.25em;
}

.prose li {
  padding-inline-start: 0.25em;
}

.prose li + li {
  margin-block-start: 0.4em;
}

.prose ul {
  list-style-type: disc;
}

.prose ul ul {
  list-style-type: "– ";
}

.prose li::marker {
  color: var(--color-text-muted);
}

.prose ol li::marker {
  color: var(--color-text);
  font-weight: 600;
  font-variant-numeric: tabular-nums;
}

.prose li > :is(ul, ol) {
  margin-block: 0.4em 0;
}

If you use Tailwind, the list-disc, list-decimal, list-inside, and marker: variant cover most of this. For example, marker:text-sky-600 styles the ::marker of every list item inside the element.

Lists and Bullets FAQ

The ::marker pseudo-element styles the native marker box that list items already generate, so you keep correct alignment and numbering for free, but you can only change fonts, color, and content. A ::before pseudo-element can be styled with any property, including backgrounds and borders, but you must remove the native marker and position everything yourself.

Safari with VoiceOver drops list semantics from lists styled with list-style none, treating them as plain groups. Adding role list to the ul or ol element restores the announcement. Other browser and screen reader combinations generally keep the semantics.

Target the marker directly with li::marker and set a color there. The list item text keeps its own color. This works in all current browsers and needs no extra markup.

Yes. Set list-style-type to a quoted string such as a dash, arrow, or emoji followed by a space. Emoji render with the system's emoji font, so they may look different across operating systems, and some screen readers will announce their names.

With native markers, use the start attribute on the ol element or the value attribute on an individual li. If you have replaced the markers with a CSS counter, set the starting point with counter-reset, remembering that the counter increments before the first item is displayed.

Outside is usually better for reading because wrapped lines align with the first line of text, creating a clean hanging indent. Inside markers can work in tight UI components where you need the marker to stay within a bordered box.

Conclusion

Lists are one of the most common text structures on any page, and their styling is often an afterthought. Modern CSS lets you handle most designs without hacks: ::marker for color and weight, string values in list-style-type for custom bullets, @counter-style for custom numbering, and CSS counters with pseudo-elements when you need full visual control.

Whatever approach you choose, keep the markup semantic, size indents and icons in em so they track the text, and restore role="list" whenever you remove native markers. Your lists will look the way the designer intended and still read correctly for everyone.

Here are some useful references for going deeper on list styling:

  1. MDN Web Docs: ::marker — the allowed properties and browser support for marker styling.
  2. MDN Web Docs: @counter-style — every descriptor and system type for custom counter styles.
  3. MDN Web Docs: Using CSS counters — counter-reset, counter-increment, and nested counters().
  4. web.dev: Custom bullets with CSS ::marker — practical examples of marker styling.
  5. W3C: WCAG 2.2 Understanding 1.4.12 Text Spacing — the spacing overrides your lists need to survive.
Share :

Related Posts

Ascenders, Descenders, and Baselines: The Anatomy of a Letterform

Ascenders, Descenders, and Baselines: The Anatomy of a Letterform

You align an icon next to a button label and it looks a pixel or two too high, no matter how you adjust vertical-align. You set overflow: hidden

Continue Reading
Are Google Fonts GDPR-Compliant?

Are Google Fonts GDPR-Compliant?

In late 2022, thousands of small business owners in Germany and Austria opened letters demanding a few hundred euros in "damages" because their websi

Continue Reading
How to Audit Web Font Performance with Lighthouse?

How to Audit Web Font Performance with Lighthouse?

A client sends you a screenshot of their PageSpeed Insights report: performance score 61, LCP 3.9 seconds, and a vague list of warnings. They want to

Continue Reading