CSS Lists: ::marker, Counters and 16 Styled Examples

Most list styling problems come down to the marker, the bullet or number sitting in front of each item. CSS handles it through the list-style properties, the ::marker pseudo-element, CSS counters and the @counter-style rule.

Below are 16 CSS list examples you can try and copy, followed by a guide to markers, counters, list layouts and the Safari VoiceOver catch.

By Bogdan Sandu · Updated October 28, 2026

Styled markers: ::marker, strings and @counter-style

Four lists that keep the native marker and change what it looks like or what it says. No ::before, no list-style: none, so the browser still lays out the hanging marker and screen readers still hear a list.

Recipe steps with ::marker

  • ::marker
  • content: counter()
  • decimal-leading-zero

Charred leek toast

Serves 2, ready in 25 minutes

  1. Halve the leeks lengthways and rinse the grit out from between the layers.
  2. Char them cut side down in a dry pan over high heat for about six minutes.
  3. Whisk crème fraîche with lemon zest, flaky salt and black pepper.
  4. Toast thick slices of sourdough and spread the cream on while still hot.
  5. Pile on the leeks, scatter chili flakes and finish with olive oil.

The numbers are the native ol markers, restyled with ::marker: a serif face, a rust color and zero-padded text from counter(list-item, decimal-leading-zero). Safari applies only the color and size, so there it falls back to plain 1. 2. 3. in rust, which still reads well.

View code
.li-rc {
  box-sizing: border-box;
  width: min(100%, 440px);
  padding: 28px 26px 24px;
  border: 1px solid #eadfcd;
  border-radius: 18px;
  background: #fffaf3;
  color: #2b2118;
}
.li-rc__title {
  margin: 0;
  font-family: "Fraunces", serif;
  font-size: 1.6rem;
  font-weight: 600;
  line-height: 1.15;
  letter-spacing: -0.01em;
  color: #2b2118;
}
.li-rc__meta {
  margin: 6px 0 18px;
  font-size: .9rem;
  color: #6b5a48;
}
.li-rc__steps {
  margin: 0;
  padding-left: 2.7em;
}
.li-rc__steps li {
  padding-left: .3em;
  line-height: 1.5;
}
.li-rc__steps li + li {
  margin-top: 12px;
}
.li-rc__steps li::marker {
  color: #b4410e;
  font-family: "Fraunces", serif;
  font-size: 1.15em;
  font-weight: 700;
  content: counter(list-item, decimal-leading-zero) "  ";
}

Changelog with string markers

  • list-style-type: "+ "
  • ::marker color
  • monospace

Release 4.2.0

Added

  • Offline drafts sync automatically when the connection comes back
  • Shortcut for duplicating the selected block

Fixed

  • Pasted tables no longer lose their header row
  • Export to PDF keeps custom page margins

Removed

  • Legacy embed API, replaced by the v3 endpoints

list-style-type accepts a quoted string, so each change type gets its own symbol without a pseudo-element: a plus for additions, an asterisk for fixes and a minus sign for removals. The markers stay outside, so long entries wrap cleanly under the text, and ::marker colors them per section.

View code
.li-cl {
  box-sizing: border-box;
  width: min(100%, 460px);
  padding: 24px 24px 20px;
  border: 1px solid #dfe3ea;
  border-radius: 14px;
  background: #fff;
  color: #1e2532;
  font-size: .95rem;
}
.li-cl__head {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: space-between;
  gap: 4px 12px;
  padding-bottom: 14px;
  border-bottom: 1px solid #e6e9ef;
}
.li-cl__version {
  margin: 0;
  font-family: ui-monospace, Consolas, monospace;
  font-size: 1.15rem;
  font-weight: 700;
  letter-spacing: 0;
  color: #1e2532;
}
.li-cl__date {
  font-size: .85rem;
  color: #5b6474;
}
.li-cl__kind {
  margin: 16px 0 6px;
  font-family: inherit;
  font-size: .78rem;
  font-weight: 700;
  letter-spacing: .08em;
  text-transform: uppercase;
}
.li-cl__kind--add { color: #15703a; }
.li-cl__kind--fix { color: #1d4ed8; }
.li-cl__kind--del { color: #b42318; }
.li-cl__list {
  margin: 0;
  padding-left: 1.6em;
  line-height: 1.5;
}
.li-cl__list li + li {
  margin-top: 4px;
}
.li-cl__list--add { list-style-type: "+  "; }
.li-cl__list--fix { list-style-type: "*  "; }
.li-cl__list--del { list-style-type: "\2212  "; }
.li-cl__list li::marker {
  font-family: ui-monospace, Consolas, monospace;
  font-weight: 700;
}
.li-cl__list--add li::marker { color: #15703a; }
.li-cl__list--fix li::marker { color: #1d4ed8; }
.li-cl__list--del li::marker { color: #b42318; }

Footnotes with @counter-style

  • @counter-style
  • system: symbolic
  • suffix

The winter ferry to Halsund left twice a day*, and the timetable changed again once the causeway opened†. Older residents still call the stone jetty the north steps‡, though the steps were removed§ long before the harbor was dredged**.

  1. Departures at 07:40 and 15:10 from November to March.
  2. The causeway carried its first cars in the spring of 1991.
  3. Recorded in the parish survey of 1908.
  4. They were cut back to make room for the fuel store.
  5. Dredging finished in 1974, deepening the berth to six meters.

A custom counter style with the symbolic system cycles through asterisk, dagger, double dagger and section sign, then doubles them, so the fifth note gets two asterisks. The name goes straight into list-style-type. A browser without @counter-style treats the unknown name as decimal and numbers the notes instead.

View code
@counter-style li-footnote {
  system: symbolic;
  symbols: "*" "\2020" "\2021" "\A7";
  suffix: "  ";
}
.li-fn {
  box-sizing: border-box;
  width: min(100%, 460px);
  padding: 28px 26px;
  border-radius: 6px;
  background: #fbf8f2;
  box-shadow: 0 1px 0 #e4ddcf, 0 12px 30px rgba(43, 33, 24, .08);
  color: #2a2620;
}
.li-fn__body {
  margin: 0 0 18px;
  font-family: "Fraunces", serif;
  font-size: 1.08rem;
  line-height: 1.65;
}
.li-fn__body sup {
  font-family: inherit;
  font-size: .7em;
  color: #8a3b12;
}
.li-fn__notes {
  margin: 0;
  padding: 14px 0 0 2.2em;
  border-top: 1px solid #ddd4c4;
  list-style-type: li-footnote;
  font-size: .86rem;
  line-height: 1.5;
  color: #4a443a;
}
.li-fn__notes li + li {
  margin-top: 4px;
}
.li-fn__notes li::marker {
  color: #8a3b12;
  font-weight: 700;
}

Chart countdown with reversed and start

  • ol reversed
  • start attribute
  • tabular-nums

This week on Static FM

Bubbling under

  1. Glass Harbour The Lowfields
  2. Night Bus Home Ada Quill
  3. Copper Sky Wren and Fable

Top five

  1. Second Language Juno Park
  2. Overpass Milo Serrat
  3. Small Hours The Lowfields
  4. Saltwater Static Ada Quill
  5. Paper Lanterns Mara Okafor

Both lists count down with the reversed attribute, and the first one also sets start="8" so it hands over to the top five. The numbers are still native markers, so no counter code is needed, and ::marker gives them the yellow serif look. The last item of the top five is the number one and gets its own size.

View code
.li-cd {
  box-sizing: border-box;
  width: min(100%, 420px);
  color: #f4f1ff;
}
.li-cd__title {
  margin: 0 0 6px;
  font-family: "Fraunces", serif;
  font-size: 1.5rem;
  font-weight: 600;
  letter-spacing: -0.01em;
  color: #f4f1ff;
}
.li-cd__label {
  margin: 16px 0 6px;
  font-size: .78rem;
  font-weight: 700;
  letter-spacing: .1em;
  text-transform: uppercase;
  color: #b9b2d6;
}
.li-cd__list {
  margin: 0;
  padding-left: 2.4em;
}
.li-cd__list li {
  padding: 7px 0 7px .5em;
  border-bottom: 1px solid #2c2942;
  line-height: 1.3;
}
.li-cd__list li::marker {
  color: #ffc857;
  font-family: "Fraunces", serif;
  font-size: 1.3em;
  font-weight: 700;
  font-variant-numeric: tabular-nums;
}
.li-cd__list--under li::marker {
  color: #b9b2d6;
  font-size: 1.05em;
}
.li-cd__song {
  font-weight: 600;
}
.li-cd__artist {
  display: block;
  font-size: .85rem;
  color: #b9b2d6;
}
.li-cd__list:not(.li-cd__list--under) li:last-child {
  border-bottom: 0;
}
.li-cd__list:not(.li-cd__list--under) li:last-child::marker {
  font-size: 1.9em;
}
.li-cd__list:not(.li-cd__list--under) li:last-child .li-cd__song {
  font-size: 1.15rem;
  color: #ffc857;
}

Numbered and nested lists

CSS counters do the numbering here: legal outlines, onboarding steps, an order tracker and a file tree. Where list-style: none removes the marker, the list carries role="list" so Safari with VoiceOver keeps announcing it.

Onboarding steps in circles

  • counter()
  • aria-current="step"
  • container query
  1. Completed: AccountEmail confirmed
  2. WorkspaceName and logo
  3. Invite teamUp to 5 seats free
  4. CalendarOptional

A counter prints each step number inside a 44px circle drawn by ::before, and a finished step swaps the number for a check mark. The list is vertical in narrow containers and turns into a horizontal stepper with connector lines once its container is wide enough, decided by a container query rather than the viewport.

View code
.li-st {
  box-sizing: border-box;
  width: min(100%, 640px);
  padding: 26px 22px 8px;
  border-radius: 16px;
  background: #fff;
  box-shadow: 0 10px 30px rgba(15, 40, 60, .08);
  color: #1d2b36;
  container-type: inline-size;
}
.li-st__list {
  margin: 0;
  padding: 0;
  list-style: none;
  counter-reset: li-step;
}
.li-st__step {
  position: relative;
  display: grid;
  grid-template-columns: 44px 1fr;
  column-gap: 14px;
  padding-bottom: 20px;
  counter-increment: li-step;
}
.li-st__step::before {
  content: counter(li-step);
  grid-row: 1 / span 2;
  display: grid;
  place-items: center;
  box-sizing: border-box;
  width: 44px;
  height: 44px;
  border: 2px solid #c3ccd6;
  border-radius: 50%;
  background: #fff;
  color: #4f5d6b;
  font-weight: 700;
}
.li-st__step::after {
  content: "";
  position: absolute;
  top: 48px;
  bottom: 4px;
  left: 21px;
  width: 2px;
  background: #d5dce4;
}
.li-st__step:last-child::after {
  display: none;
}
.li-st__label {
  align-self: end;
  font-weight: 700;
}
.li-st__hint {
  font-size: .86rem;
  color: #56646f;
}
.li-st__step--done::before {
  content: "\2713";
  border-color: #0f766e;
  background: #0f766e;
  color: #fff;
}
.li-st__step--done::after {
  background: #0f766e;
}
.li-st__step[aria-current="step"]::before {
  border-color: #0f766e;
  color: #0f766e;
  box-shadow: 0 0 0 5px rgba(15, 118, 110, .16);
}
.li-st__step[aria-current="step"] .li-st__label {
  color: #0f766e;
}
.li-st__sr {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}
@container (min-width: 540px) {
  .li-st__list {
    display: grid;
    grid-template-columns: repeat(4, 1fr);
  }
  .li-st__step {
    grid-template-columns: 1fr;
    justify-items: center;
    row-gap: 2px;
    text-align: center;
  }
  .li-st__step::before {
    grid-row: auto;
    margin-bottom: 8px;
  }
  .li-st__step::after {
    top: 21px;
    bottom: auto;
    left: calc(50% + 28px);
    width: calc(100% - 56px);
    height: 2px;
  }
  .li-st__label {
    align-self: auto;
  }
}

Order tracking timeline

  • ol + time
  • ::before dots
  • aria-current

Order 48213

Arriving Thursday, Oct 29

  1. Completed: Order placed
  2. Completed: Packed at the Leeds warehouse
  3. In transit to your local depot
  4. Out for deliveryExpected Oct 29
  5. DeliveredSignature not needed

An ordered list is the honest markup for events in sequence. Dots and the connecting rail are pseudo-elements on each li, finished events are filled, the current one carries aria-current="step" and a soft pulse that only runs when the visitor has not asked for reduced motion.

View code
.li-tr {
  box-sizing: border-box;
  width: min(100%, 420px);
  padding: 24px 24px 10px;
  border: 1px solid #e3def5;
  border-radius: 16px;
  background: #fff;
  color: #221d3a;
}
.li-tr__head {
  margin-bottom: 18px;
}
.li-tr__title {
  margin: 0;
  font-family: inherit;
  font-size: 1.15rem;
  font-weight: 700;
  letter-spacing: 0;
  color: #221d3a;
}
.li-tr__eta {
  margin: 2px 0 0;
  font-size: .9rem;
  color: #5b21b6;
  font-weight: 600;
}
.li-tr__list {
  margin: 0;
  padding: 0;
  list-style: none;
}
.li-tr__ev {
  position: relative;
  display: grid;
  padding: 0 0 18px 30px;
}
.li-tr__ev::before {
  content: "";
  position: absolute;
  top: 4px;
  left: 3px;
  box-sizing: border-box;
  width: 14px;
  height: 14px;
  border: 2px solid #b8b0d3;
  border-radius: 50%;
  background: #fff;
}
.li-tr__ev::after {
  content: "";
  position: absolute;
  top: 20px;
  bottom: 0;
  left: 9px;
  width: 2px;
  background: #e2ddf0;
}
.li-tr__ev:last-child::after {
  display: none;
}
.li-tr__ev--done::before {
  border-color: #6d28d9;
  background: #6d28d9;
}
.li-tr__ev--done::after {
  background: #6d28d9;
}
.li-tr__ev[aria-current="step"]::before {
  border-color: #6d28d9;
  box-shadow: 0 0 0 4px rgba(109, 40, 217, .18);
}
.li-tr__what {
  font-weight: 600;
  line-height: 1.35;
}
.li-tr__ev:not(.li-tr__ev--done):not([aria-current]) .li-tr__what {
  color: #5f5a73;
  font-weight: 500;
}
.li-tr__time {
  font-size: .84rem;
  color: #5f5a73;
}
.li-tr__sr {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}
@media (prefers-reduced-motion: no-preference) {
  .li-tr__ev[aria-current="step"]::before {
    animation: li-tr-pulse 2s ease-out infinite;
  }
}
@keyframes li-tr-pulse {
  0% { box-shadow: 0 0 0 0 rgba(109, 40, 217, .35); }
  70% { box-shadow: 0 0 0 9px rgba(109, 40, 217, 0); }
  100% { box-shadow: 0 0 0 0 rgba(109, 40, 217, 0); }
}

File tree with guide lines

  • nested ul
  • details / summary
  • border-left lines
  • src
    • components
      • Button.tsx
      • Card.tsx
      • Menu.tsx
    • styles
      • tokens.css
      • lists.css
    • main.ts
  • package.json
  • README.md

Click a folder name to open or close it.

Each nested ul draws the vertical guide with border-left, and every li adds its horizontal elbow with ::before. On the last child an ::after patch hides the rest of the vertical line, which gives the clean corner. Folders are details elements, so they open and close with no script and work from the keyboard.

View code
.li-tree {
  box-sizing: border-box;
  width: min(100%, 320px);
  min-height: 330px;
  padding: 18px 20px;
  border: 1px solid #2c3242;
  border-radius: 12px;
  background: #161a23;
  color: #c9d1e0;
  font-family: ui-monospace, Consolas, monospace;
  font-size: .9rem;
  line-height: 1.75;
}
.li-tree__list {
  margin: 0;
  padding: 0;
  list-style: none;
}
.li-tree__list--sub {
  margin-left: 8px;
  padding-left: 16px;
  border-left: 1px solid #3b4357;
}
.li-tree__list--sub > li {
  position: relative;
}
.li-tree__list--sub > li::before {
  content: "";
  position: absolute;
  top: .9em;
  left: -16px;
  width: 11px;
  border-top: 1px solid #3b4357;
}
.li-tree__list--sub > li:last-child::after {
  content: "";
  position: absolute;
  top: calc(.9em + 1px);
  bottom: 0;
  left: -17px;
  width: 3px;
  background: #161a23;
}
.li-tree__dir {
  display: flex;
  align-items: center;
  gap: 6px;
  min-height: 28px;
  border-radius: 4px;
  color: #e8c07a;
  cursor: pointer;
  list-style: none;
}
.li-tree__dir::-webkit-details-marker {
  display: none;
}
.li-tree__dir::before {
  content: "";
  width: 0;
  height: 0;
  border-top: 4px solid transparent;
  border-bottom: 4px solid transparent;
  border-left: 6px solid #8b95a8;
}
details[open] > .li-tree__dir::before {
  transform: rotate(90deg);
}
.li-tree__dir:hover {
  background: #222838;
}
.li-tree__dir:focus-visible {
  outline: 2px solid #7aa2f7;
  outline-offset: 1px;
}
.li-tree__file {
  padding-left: 12px;
}
@media (prefers-reduced-motion: no-preference) {
  .li-tree__dir::before {
    transition: transform .15s ease;
  }
}

Checklists and icon lists

Lists whose marker is a picture: a list-style-image checklist, icons drawn with mask-image so they follow currentColor, pricing features and a task list that reacts to its own checkboxes with :has().

Checklist with list-style-image

  • list-style-image
  • SVG marker
  • type fallback

Launch checklist

3 of 5 done

  • Done: DNS records point at the new host
  • Done: Redirects from the old URLs return 301
  • Done: Sitemap submitted to search consoles
  • To do: Open Graph images checked on every template
  • To do: Uptime alerts routed to the on-call phone

The marker is an 18px SVG file set with list-style-image, and the shorthand also names square, which shows if the image fails to load. The image cannot be resized from CSS, so the SVG carries its own width and height. Done and open items swap the image, and hidden text says which is which.

View code
.li-ck {
  box-sizing: border-box;
  width: min(100%, 420px);
  padding: 24px 24px 20px;
  border-radius: 14px;
  background: #fff;
  box-shadow: 0 1px 2px rgba(16, 40, 24, .08), 0 8px 24px rgba(16, 40, 24, .07);
  color: #1b2a20;
}
.li-ck__title {
  margin: 0;
  font-family: inherit;
  font-size: 1.15rem;
  font-weight: 700;
  letter-spacing: 0;
  color: #1b2a20;
}
.li-ck__meta {
  margin: 2px 0 14px;
  font-size: .86rem;
  color: #15703a;
  font-weight: 600;
}
.li-ck__list {
  margin: 0;
  padding-left: 1.7em;
  list-style: square url("li-open.svg") outside;
  line-height: 1.45;
}
.li-ck__list li {
  padding: 8px 0 8px .45em;
  border-top: 1px solid #edf0ed;
  color: #374151;
}
.li-ck__list .li-ck__item--done {
  list-style-image: url("li-check.svg");
  color: #4b5563;
  text-decoration: line-through;
  text-decoration-color: #9ca3af;
}
.li-ck__sr {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

Icon list with mask-image

  • mask-image
  • currentColor
  • inline SVG data URI

Studio Halden

Furniture and interior design

Hover or focus a contact line.

Each icon is a ::before box filled with currentColor and cut to shape by an SVG mask, so it always matches the text color. Hover or focus a line and the icon changes color with the link, with no second icon file. The list drops its bullets, so it carries role="list".

View code
.li-ct {
  box-sizing: border-box;
  width: min(100%, 400px);
  padding: 24px 22px 16px;
  border-radius: 18px;
  background: #f1f7f8;
  border: 1px solid #d5e6e9;
  color: #22343a;
}
.li-ct__title {
  margin: 0;
  font-family: "Fraunces", serif;
  font-size: 1.45rem;
  font-weight: 600;
  letter-spacing: -0.01em;
  color: #16292f;
}
.li-ct__meta {
  margin: 2px 0 12px;
  font-size: .88rem;
  color: #4b6168;
}
.li-ct__list {
  margin: 0;
  padding: 0;
  list-style: none;
}
.li-ct__line {
  display: flex;
  align-items: center;
  gap: 12px;
  min-height: 44px;
  overflow-wrap: anywhere;
  color: #2e4249;
  text-decoration: none;
  border-radius: 8px;
  transition: color .2s ease, background-color .2s ease;
}
a.li-ct__line {
  padding: 0 8px;
  margin: 0 -8px;
}
span.li-ct__line {
  padding: 0 8px;
  margin: 0 -8px;
}
.li-ct__line::before {
  content: "";
  flex: none;
  width: 20px;
  height: 20px;
  background-color: currentColor;
  -webkit-mask: var(--li-ct-icon) center / contain no-repeat;
  mask: var(--li-ct-icon) center / contain no-repeat;
}
a.li-ct__line:hover,
a.li-ct__line:focus-visible {
  color: #0b6b80;
  background-color: #e0eff2;
}
a.li-ct__line:focus-visible {
  outline: 2px solid #0b6b80;
  outline-offset: 1px;
}
.li-ct__line--phone {
  --li-ct-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='6.5' y='2' width='11' height='20' rx='2.5'/%3E%3Cpath d='M11 18h2'/%3E%3C/svg%3E");
}
.li-ct__line--mail {
  --li-ct-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='5' width='18' height='14' rx='2'/%3E%3Cpath d='m3.5 7 8.5 6 8.5-6'/%3E%3C/svg%3E");
}
.li-ct__line--pin {
  --li-ct-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 21s-7-6.2-7-11.5a7 7 0 0 1 14 0C19 14.8 12 21 12 21z'/%3E%3Ccircle cx='12' cy='9.5' r='2.5'/%3E%3C/svg%3E");
}
.li-ct__line--clock {
  --li-ct-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='9'/%3E%3Cpath d='M12 7v5l3 2'/%3E%3C/svg%3E");
}

Pricing feature lists

  • mask-image icons
  • visually hidden text
  • flex-wrap

Starter

$0 forever

  • 3 projects
  • Basic analytics
  • Not included: Custom domain
  • Not included: Priority support

Studio

$18 per month

  • Unlimited projects
  • Full analytics and exports
  • Custom domain with SSL
  • Priority support

Included and excluded features share one list, told apart by a check or a cross drawn with mask-image and by hidden text that reads Not included for screen reader users. Color alone never carries the difference: excluded lines are also lighter and use a different icon.

View code
.li-pr {
  --li-pr-check: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m5 12.5 4.5 4.5L19 7.5'/%3E%3C/svg%3E");
  --li-pr-cross: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.5' stroke-linecap='round'%3E%3Cpath d='M7 7l10 10M17 7 7 17'/%3E%3C/svg%3E");
  display: flex;
  flex-wrap: wrap;
  gap: 16px;
  width: min(100%, 560px);
}
.li-pr__card {
  box-sizing: border-box;
  flex: 1 1 220px;
  padding: 22px 20px;
  border: 1px solid #dde1e8;
  border-radius: 16px;
  background: #fff;
  color: #1a2233;
}
.li-pr__card--pro {
  border-color: #0f172a;
  background: #0f172a;
  color: #e7ecf5;
}
.li-pr__plan {
  margin: 0;
  font-family: inherit;
  font-size: 1rem;
  font-weight: 700;
  letter-spacing: 0;
  color: inherit;
}
.li-pr__price {
  margin: 4px 0 16px;
  font-size: .88rem;
  color: #5a6475;
}
.li-pr__card--pro .li-pr__price {
  color: #aeb8c9;
}
.li-pr__amount {
  font-family: "Fraunces", serif;
  font-size: 2.1rem;
  font-weight: 600;
  color: #1a2233;
}
.li-pr__card--pro .li-pr__amount {
  color: #fff;
}
.li-pr__list {
  margin: 0 0 18px;
  padding: 0;
  list-style: none;
  font-size: .92rem;
}
.li-pr__list li {
  display: flex;
  align-items: flex-start;
  gap: 10px;
  padding: 5px 0;
  line-height: 1.4;
}
.li-pr__list li::before {
  content: "";
  flex: none;
  width: 18px;
  height: 18px;
  margin-top: 1px;
  background-color: currentColor;
  -webkit-mask: var(--li-pr-check) center / contain no-repeat;
  mask: var(--li-pr-check) center / contain no-repeat;
}
.li-pr__yes::before {
  color: #15803d;
}
.li-pr__card--pro .li-pr__yes::before {
  color: #a3e635;
}
.li-pr__no {
  color: #6b7280;
}
.li-pr__list .li-pr__no::before {
  -webkit-mask-image: var(--li-pr-cross);
  mask-image: var(--li-pr-cross);
  color: #9ca3af;
}
.li-pr__cta {
  width: 100%;
  min-height: 44px;
  border: 1px solid #1a2233;
  border-radius: 10px;
  background: #fff;
  color: #1a2233;
  font: inherit;
  font-weight: 700;
  cursor: pointer;
  transition: background-color .2s ease, color .2s ease;
}
.li-pr__cta:hover {
  background: #1a2233;
  color: #fff;
}
.li-pr__card--pro .li-pr__cta {
  border-color: #a3e635;
  background: #a3e635;
  color: #0f172a;
}
.li-pr__card--pro .li-pr__cta:hover {
  background: #bef264;
  color: #0f172a;
}
.li-pr__cta:focus-visible {
  outline: 3px solid #2563eb;
  outline-offset: 2px;
}
.li-pr__sr {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

Task list with :has() and counters

  • :has(:checked)
  • counter-increment
  • accent-color

Before the workshop

Everything is ready for Friday.

Tick the boxes.

li:has(:checked) strikes through finished tasks, and the same selector bumps a second counter, so the footer prints 2 of 5 done with no script at all. When no unchecked box is left, :not(:has()) on the wrapper reveals the all-done line. Counters only count elements that come earlier in the document, so the total sits after the list.

View code
.li-tk {
  box-sizing: border-box;
  width: min(100%, 400px);
  padding: 22px 22px 18px;
  border-radius: 16px;
  background: #fff7fa;
  border: 1px solid #f3d3e0;
  color: #2c1a22;
  counter-reset: li-done li-all;
}
.li-tk__title {
  margin: 0 0 10px;
  font-family: inherit;
  font-size: 1.15rem;
  font-weight: 700;
  letter-spacing: 0;
  color: #2c1a22;
}
.li-tk__list {
  margin: 0;
  padding: 0;
  list-style: none;
}
.li-tk__item {
  counter-increment: li-all;
  border-bottom: 1px solid #f3dbe5;
}
.li-tk__item:has(:checked) {
  counter-increment: li-all li-done;
}
.li-tk__label {
  display: flex;
  align-items: center;
  gap: 12px;
  min-height: 44px;
  cursor: pointer;
}
.li-tk__box {
  flex: none;
  width: 20px;
  height: 20px;
  margin: 0;
  accent-color: #be185d;
  cursor: pointer;
}
.li-tk__box:focus-visible {
  outline: 2px solid #be185d;
  outline-offset: 2px;
}
.li-tk__item:has(:checked) .li-tk__label {
  color: #7a6570;
  text-decoration: line-through;
  text-decoration-color: #c98aa5;
}
.li-tk__count {
  margin: 12px 0 0;
  font-size: .88rem;
  font-weight: 600;
  color: #9d174d;
}
.li-tk__count::before {
  content: counter(li-done) " of " counter(li-all) " done";
}
.li-tk__done {
  display: none;
  margin: 6px 0 0;
  padding: 8px 12px;
  border-radius: 8px;
  background: #be185d;
  color: #fff;
  font-size: .9rem;
  font-weight: 600;
}
.li-tk:not(:has(.li-tk__box:not(:checked))) .li-tk__done {
  display: block;
}

List layouts

The same ul, ol and dl elements arranged as a key and value spec sheet, an inline sentence, newspaper columns and a horizontal navigation bar.

Spec sheet with a dl

  • dl / dt / dd
  • div wrappers
  • grid rows

Orrin S2 portable speaker

Drivers
Two 50 mm full-range, one passive radiator
Battery
14 hours at half volume
Charging
USB-C, 2.5 hours to full
Bluetooth
5.3, two devices at once
Rating
IP67 dust and water resistant
Weight
640 g

A description list is the right element for name and value pairs. The HTML Standard allows each dt and dd pair to sit in a div, which makes every row one grid with a fixed label column and a flexible value column, plus a rule between rows.

View code
.li-sp {
  box-sizing: border-box;
  width: min(100%, 440px);
  padding: 24px;
  border-radius: 4px;
  background: #fff;
  border: 1px solid #111;
  color: #111;
}
.li-sp__title {
  margin: 0 0 12px;
  font-family: inherit;
  font-size: 1.1rem;
  font-weight: 700;
  letter-spacing: -0.01em;
  color: #111;
}
.li-sp__list {
  margin: 0;
  border-top: 2px solid #111;
}
.li-sp__row {
  display: grid;
  grid-template-columns: minmax(5.5rem, 34%) 1fr;
  gap: 12px;
  padding: 10px 0;
  border-bottom: 1px solid #d4d4d4;
  font-size: .92rem;
  line-height: 1.4;
}
.li-sp__row dt {
  font-size: .76rem;
  font-weight: 700;
  letter-spacing: .08em;
  line-height: 1.85;
  text-transform: uppercase;
  color: #525252;
}
.li-sp__row dd {
  margin: 0;
  font-variant-numeric: tabular-nums;
}

Inline comma-separated list

  • display: inline
  • li::after
  • :nth-last-child(2)

How we cut our CSS bundle in half

Written by
  • Ines Duarte
  • Tom Brandt
  • Keiko Mori
Reviewed by
  • Sam Okoye
  • Lena Vogt
Filed under
  • Performance
  • Build tools
  • Cascade layers
  • Case studies

The authors stay a real ul, shown inline. Every item but the last gets a comma from ::after, and :nth-last-child(2) swaps it for and, so two, three or five names all read as a proper sentence. Inline items lose their markers, so the lists carry role="list".

View code
.li-in {
  box-sizing: border-box;
  width: min(100%, 460px);
  padding: 26px 24px;
  border-top: 4px solid #2b2b2b;
  background: #f7f4ee;
  color: #2b2b2b;
  font-size: .95rem;
  line-height: 1.55;
}
.li-in__title {
  margin: 0 0 14px;
  font-family: "Fraunces", serif;
  font-size: 1.45rem;
  font-weight: 600;
  line-height: 1.2;
  letter-spacing: -0.01em;
  color: #1c1c1c;
}
.li-in__line + .li-in__line {
  margin-top: 6px;
}
.li-in__label {
  color: #5c5750;
}
.li-in__list {
  display: inline;
  margin: 0;
  padding: 0;
  list-style: none;
}
.li-in__list li {
  display: inline;
  font-weight: 600;
}
.li-in__list li:not(:last-child)::after {
  content: ", ";
  font-weight: 400;
}
.li-in__list li:nth-last-child(2)::after {
  content: " and ";
}
.li-in__line--tags .li-in__list li {
  font-weight: 400;
  font-style: italic;
  color: #8a3b12;
}
.li-in__line--tags .li-in__list li::after {
  font-style: normal;
  color: #2b2b2b;
}

Two-column list with columns

  • column-width
  • column-rule
  • break-inside: avoid

Pantry restock

  • Rolled oats 1 kg
  • Red lentils 500 g
  • Basmati rice 2 kg
  • Chickpeas 4 tins
  • Chopped tomatoes 6 tins
  • Olive oil 1 l
  • Smoked paprika
  • Ground cumin
  • Plain flour 1.5 kg
  • Brown sugar
  • Peanut butter, crunchy
  • Coffee beans 250 g

column-width asks for columns of at least 6.5rem and lets the browser decide how many fit, so the list shows two columns on a phone and three on a wider card. break-inside: avoid keeps a wrapped item in one piece, and the native square markers survive because nothing touches list-style.

View code
.li-co {
  box-sizing: border-box;
  width: min(100%, 560px);
  padding: 24px;
  border-radius: 12px;
  background: #f4ead8;
  color: #3a2c17;
}
.li-co__title {
  margin: 0 0 14px;
  font-family: "Fraunces", serif;
  font-size: 1.4rem;
  font-weight: 600;
  letter-spacing: -0.01em;
  color: #3a2c17;
}
.li-co__list {
  margin: 0;
  padding-left: 1.1em;
  list-style-type: square;
  column-width: 6.5rem;
  column-gap: 2.2em;
  column-rule: 1px dashed #c9b48e;
  font-size: .93rem;
  line-height: 1.4;
}
.li-co__list li {
  padding: 4px 0 4px .2em;
  break-inside: avoid;
}
.li-co__list li::marker {
  color: #a35f0b;
}
.li-co__qty {
  font-size: .82rem;
  color: #6e5631;
  white-space: nowrap;
}

What Are CSS Lists?

A marker exists because the element computes to display: list-item. According to MDN, only li and summary do that by default, but any element can opt in with that display value. The W3C’s CSS Lists and Counters Module Level 3 says that only list items generate a marker box, inserted as the item’s first child ahead of any ::before content. Browsers also add spacing around ul and ol elements, and the list properties cover that part too.

  • list-style-type, list-style-position, list-style-image and the list-style shorthand for the marker itself
  • ::marker, which styles the bullet or number directly
  • counter-reset, counter-increment and the counter() and counters() functions for numbering
  • @counter-style for custom markers

ul, ol and li come from HTML, and CSS only decides how the resulting boxes look.

The list-style properties live in CSS Lists and Counters Module Level 3. @counter-style is defined separately in CSS Counter Styles Level 3, and ::marker belongs to CSS Pseudo-Elements Module Level 4.

What Default Styles Do Browsers Apply to Lists?

Browsers indent ul and ol by 40px and add 1em of vertical margin, according to the suggested rendering in the WHATWG HTML Standard. Most CSS frameworks replace those values.

SourceLeft paddingVertical marginMarker
Browser default (HTML Standard)40px1em, 0 when nesteddisc, circle, square; decimal for ol
Bootstrap Reboot 5.32rem0 top, 1rem bottom, 0 bottom when nestedUnchanged
Tailwind Preflight00none

Bullet shapes step down with depth. A ul nested inside any other list gets circle, and a third level gets square.

Bootstrap keeps the markers and swaps the spacing for rem units, as its Reboot documentation describes. Its list-unstyled class strips the marker from immediate children only, so a nested ul keeps its bullets unless it carries the class too.

Tailwind goes the other direction. Its Preflight docs state that ordered and unordered lists ship with no bullets, numbers, margin or padding, and the list-disc utility brings the bullets back.

A reset touches spacing and markers and nothing else. The li keeps display: list-item and ol and ul keep their list-item counter, so numbering still runs when a marker is switched back on.

The HTML Standard’s rendering section has an exception worth knowing. Its quirks-mode rules set list-style-position: inside on li, so a page without a doctype is expected to place markers differently.

Which list-style-type Value Fits Which List?

list-style-type takes a predefined keyword, a string, a name from @counter-style, or none.

Bullets for a ul come from disc (the default), circle and square. An ol has decimal, decimal-leading-zero, lower-alpha, upper-alpha, lower-roman, upper-roman and lower-greek. A quoted string covers a single character, including a Unicode escape such as “\1F44D”, and none removes the marker entirely.

The property follows the list item, not the list type. A ul can take decimal just fine, and an ol can take square.

Marker color comes from the element’s computed color. Some numbering systems need a suitable font installed, MDN notes.

Nested lists catch people out. User-agent stylesheets set list-style-type on ul, ol and menu, so a nested list ignores the value set on its parent. Add list-style-type: inherit to the nested list to carry the value down.

Old HTML type attributes still count. ol type=”a” maps to lower-alpha through a presentational hint, and because those hints carry zero specificity at the author level, any CSS rule overrides them.

MDN lists list-style-type as Baseline widely available, with support across browsers since July 2015.

How Do list-style-position, list-style-image and the Shorthand Work?

list-style-position decides where the marker sits, and list-style-image replaces it with a picture. The list-style shorthand rolls both into one declaration, together with list-style-type.

list-style-position: inside versus outside

The CSS Lists specification defaults the property to outside. The marker box sits beside the principal block box and hangs in the margin, so wrapped lines align with the first line of text.

With inside, the marker becomes the first inline box and wrapped lines flow back under it.

One browser gap matters here. When a block element is the first child of an inside item, MDN says the block drops to the line after the marker.

MDN’s compatibility notes record that Safari, including iOS, keeps the marker on the same line as the block.

After a padding reset, outside markers hang into the left margin and get clipped by an ancestor with overflow: hidden. Switch to inside there.

Using list-style-image

list-style-image takes a url(), an image function such as linear-gradient(), or none. The initial value is none. It applies to list items and is inherited, so it can sit on the ul.

The property gives no control over image size. Tutorial Republic suggests a background-image on li as the workaround, and for anything beyond a tiny icon that is the better tool.

The CSS Lists Level 3 specification adds one wrinkle: list-style-type: none suppresses the marker unless list-style-image holds a valid image.

The list-style shorthand

The shorthand accepts one to three values in any order, according to MDN.

Omitted values return to their initial value, and that’s the trap. list-style: square also sets the position to outside and the image to none (HTML Dog), so a shorthand declared after list-style-position: inside undoes it.

When a type and an image are both given, the type renders if the image fails to load.

keydata shows the expansion: list-style: url(“circle.gif”) none inside equals an image, a type of none and an inside position.

How Do You Style List Markers With ::marker?

Styling the marker box directly is what ::marker is for, though it accepts only a short list of CSS properties.

According to MDN, that list covers all font properties, color, content, white-space, direction and unicode-bidi, plus the animation and transition properties. MDN now also lists the counter properties, quotes and text-combine-upright, though only Firefox 147 and later applies the counter properties and quotes on ::marker. Backgrounds and margins are not applied, according to CSS Portal. The CSS Lists specification says any property can be set on ::marker, but only a defined subset has an effect.

Selectors work as usual. li::marker colors every marker and li:nth-child(2)::marker targets one (web.dev), and the pseudo-element applies to any display: list-item element, summary included.

Change the marker text with content, not list-style-type. When a design needs a background, a transform or layout control, the workaround is list-style: none plus a ::before on each li, at the price of rebuilding the marker by hand.

Sources disagree on support. MDN marks ::marker as limited availability and not Baseline, while CSS Portal and Savvy state that all modern browsers support it. The gap is Safari: MDN’s compatibility data says Safari applies only color and font-size, so test every other ::marker property you rely on there.

These are the first browser versions to ship ::marker:

  • Chrome 86 (web.dev, 2020)
  • Edge 86 (web.dev, 2020)
  • Firefox 68 (web.dev, 2020)
  • Safari 11.1 (web.dev, 2020), still limited to color and font-size (MDN compatibility data, as of October 2026)

Chrome DevTools inspects and edits ::marker styles like any other pseudo-element, so debugging a stubborn bullet takes one click in the Elements panel.

How Do CSS Counters Number List Items?

counter-reset creates a counter, counter-increment advances it, and counter() or counters() prints it through the content property.

counter-reset can also set a starting value. counter-increment adds to the counter, and a negative value counts down. counter-set writes a value into a counter and creates one only if none exists on the element yet. counters() takes a name, a separator and an optional counter style.

Ordered lists already run on a built-in list-item counter. MDN notes that counter-increment on li changes the step or starting point of native numbering.

To number a nested outline, work through these steps.

  1. Strip the native marker with ol { list-style: none; }
  2. Create the counter on every list with ol { counter-reset: section; }, which gives each nested ol its own instance
  3. Advance it on each item with li { counter-increment: section; }
  4. Print it with li::before { content: counters(section, “.”) ” “; } to get labels like 1.1, 1.2 and 2.1

Printing the same counter through li::marker content keeps the native marker and skips the first step, with the browser-support caveats that apply to ::marker.

Counters only generate text. A div numbered with counter() looks like a list item but exposes no list semantics.

Gihyo noted in 2023 that generated text from ::before, ::after and ::marker still cannot be selected in browsers, so a copied list loses its numbers.

How Does @counter-style Create Custom Markers?

Reach for @counter-style when list-style-type has no keyword for the numbering you need, such as a symbol cycle or a regional alphabet.

A rule carries a name and a set of descriptors. The name then goes straight into list-style-type or into counter(), according to MDN.

The system descriptor decides how counter values map to symbols (cyclic, fixed, symbolic, alphabetic, numeric, additive or extends). symbols holds the strings or images to use, and additive-symbols takes its place in the additive system. suffix puts text after the marker, with prefix as its counterpart. fallback names the style used when a value falls outside the system, decimal by default. range, pad, negative and speak-as cover value limits, padding, negative signs and speech output.

MDN’s own example gets by with a fixed system, the circled letters Ⓐ to Ⓩ as symbols and a space as suffix.

The fixed system only covers as many items as it has symbols. Past that point the fallback style takes over, as Mozilla Hacks explained in 2015.

Sources disagree on support here too. Caniuse lists first support in Firefox 33, Chrome and Edge 91, and Safari 17, yet labels every one of them as partial support, in Chrome and Firefox because image symbols are missing. The web-features data from web-platform-dx counts @counter-style as Baseline widely available since March 2026.

Gihyo reported in September 2023 that Safari 17.0 closed the last gap between engines. MDN’s compatibility data records speak-as as missing in Safari, so the descriptors are the part to test.

Which Technique Fits Which Job?

Use the lightest technique that does the job. A list-style-type keyword comes first, ::marker second, counters or @counter-style when the numbering is the problem, and ::before only as a last resort.

TechniqueControlsStops working when
list-style-typeMarker shape or numbering keywordThe marker needs its own color or size
::markerColor, font and content of the markerYou need a background, margin or transform
CountersOutline numbering such as 1.1 and 1.2Numbers must be selectable text
@counter-styleReusable custom numbering and symbol cyclesA descriptor you rely on lacks support
::before with list-style: noneBackgrounds, transforms and layout on the markerYou are tired of rebuilding spacing by hand

An emoji bullet shows the point. A string in list-style-type does the job, and a @counter-style rule only adds code.

Most tutorials jump to ::before for custom bullets. Try ::marker with content first, because it keeps the native marker and leaves one less thing to rebuild.

How to Build a Horizontal List or Navigation Menu

Set display: flex on the ul, strip the markers and padding, and let gap handle the spacing between items.

Flexbox gives you one row, with gap for spacing and flex-wrap for narrow screens. Inline-block works too, though you have to deal with whitespace gaps between items. Floats are legacy and need a clearfix on the parent, and grid suits equal-width columns.

Inline-block keeps the whitespace that sits between tags in the HTML source. CSS Working Group member Tab Atkins Jr. noted on the www-style list on 31 December 2009 that floats and flexbox ignore it.

A navigation menu is still a list of links, so keep the ul and li in the markup. Stripping the markers in that step is exactly what triggers the Safari behavior covered next.

Bootstrap’s list-inline and list-inline-item classes do the same strip-and-space job with two classes, per its documentation.

When items need equal-width columns instead of a single row, grid fits better.

When Does list-style: none Remove List Semantics?

Only in Safari with VoiceOver, and WebKit treats the behavior as intentional.

WebKit’s James Craig explained the rule in a 2018 Bugzilla reply. It separates layout lists from data lists, the way WebKit separates layout tables from data tables. His advice for overriding it was an explicit role=”list”.

  • In March 2017, bug 170179 reports that VoiceOver stops announcing a navigation list once list-style is none
  • In January 2019, Scott O’Hara shows that any CSS removing the bullets or numbers triggers it on macOS and iOS, while Chrome with VoiceOver is unaffected
  • In December 2025, bug 304137 reports iOS VoiceOver reading such a list as plain text while macOS announces it
  • In February 2026, WebKit closes 304137 as a duplicate of bug 122320, the layout-list heuristic

When a list is recognized, VoiceOver announces list start, item X of Y and list end, per the WebKit report. When it is not, the items read as plain lines.

Sources disagree on how far the problem reaches. cssence reproduced the bug in Safari 15 in 2022, while a W3C WAI mailing list test the same year found item counts announced from Safari 14.1 onward on macOS.

The December 2025 WebKit report sides with the second result for macOS and adds that iOS still drops the list. MDN’s list-style reference lists an exception for lists nested inside nav, so test your own markup in Safari before deciding.

Each fix comes with a cost.

  • role=”list” is one attribute, but validators flag it as redundant
  • A zero-width space in li::before needs no markup change, though the added content must not interfere with clicks
  • list-style with an empty SVG data URI restores semantics in Safari, but O’Hara saw a gap above each item in IE 11 and Edge 42

The ARIA in HTML specification calls role=”list” on a ul generally unnecessary, then explicitly allows it to reinstate the role when a browser suppresses list semantics after markers are removed. Expect the validator warning anyway (Rocket Validator).

The loss only affects screen reader users, so it never shows up in a visual check. That makes it a web accessibility test item rather than a styling bug.

Add role=”list” to navigation and other lists where the item count helps the listener, and leave decorative lists alone. WebKit’s heuristic exists to cut announcements that add noise.

CSS Lists FAQ

How do you start an ordered list at a different number or count down?

Use the HTML attributes. start and reversed set the first number and the direction, and a value attribute on li jumps a single item to any number. The HTML Standard maps all three onto the list-item counter, so author CSS overrides them.

Can a list be split into multiple columns?

Yes. Set column-count on the ul, or switch it to display: grid with a column template. Multi-column layout fills each column top to bottom, while grid fills row by row.

Add break-inside: avoid on li so an item never splits across two columns.

Which selector targets a nested list without touching its parent?

A descendant selector such as ul ul reaches every nested level, while ul > li > ul hits only the first one. Set list-style-type, margin-block and padding-inline-start on that selector.

Is marker-offset still valid for the gap between marker and text?

No. Older tutorials still list marker-offset, but CSS Lists and Counters Module Level 3 does not define it.

Add padding-inline-start to the li instead. An outside marker sits beyond the principal box, so that padding widens the gap.

Does list-style work on definition lists?

Not by default. The HTML Standard gives dl, dt and dd display: block, so they generate no marker box.

Set display: list-item on dd and the list-style properties apply to it.

Scoping List Resets So Body Copy Keeps Its Bullets

CSS lists stay predictable when the reset targets a class on navigation lists and leaves the bare ul and ol selectors untouched, so body copy keeps its default markers and indentation.

Scope the base list styles to the content container first, add one reset class for navigation lists, and test the navigation in Safari with VoiceOver last. The order matters because each step removes a conflict the next one would otherwise create.

The trade-off is one extra class per navigation list in the markup. That costs less than auditing every ul in the article text.

Framework choice shifts spacing too. Bootstrap’s 2rem padding equals 32px at a 16px root, 8px less than the 40px browser default.

The Safari advice holds as of October 27, 2026 and changes only if WebKit changes or standardizes its layout-list heuristic. WebKit called it by design when it closed bug 304137 in February 2026, and noted ongoing ARIA discussions about standardizing such heuristics. Teams building with utilities can apply the same scoping through Tailwind list utilities.