Styling Lists & Navigation Menus
Why List Styling Matters
HTML lists are among the most versatile structural elements on the web. Unordered lists (<ul>), ordered lists (<ol>), and description lists (<dl>) appear everywhere -- from simple bullet points and numbered instructions to navigation menus, breadcrumbs, pagination controls, and tag clouds. The default browser styling for lists is functional but visually plain: a simple disc bullet or decimal number with generous left padding. Mastering CSS list styling lets you transform these basic elements into polished, professional UI components while keeping the underlying HTML semantic and accessible.
In this lesson, you will learn every CSS property related to list styling, explore the powerful ::marker pseudo-element, build custom counters for complex numbering systems, and construct real-world navigation patterns entirely from HTML lists. By the end, you will be able to take a plain <ul> or <ol> and turn it into any navigational or informational component your design requires.
The list-style-type Property
The list-style-type property controls the marker (bullet or number) that appears before each list item. This property applies to elements with display: list-item, which includes <li> elements by default. It accepts a wide range of keyword values for both unordered and ordered lists, and you can even supply a custom string.
Common Values for Unordered Lists
Unordered lists use symbolic markers. The most common values are disc (a filled circle, the default), circle (a hollow circle), square (a filled square), and none (no marker at all). These simple markers cover most basic needs.
Unordered List Marker Types
/* Default filled circle */
ul.default {
list-style-type: disc;
}
/* Hollow circle */
ul.hollow {
list-style-type: circle;
}
/* Filled square */
ul.square {
list-style-type: square;
}
/* No marker at all */
ul.clean {
list-style-type: none;
}
Common Values for Ordered Lists
Ordered lists support a much larger set of numbering systems. The default is decimal (1, 2, 3...), but CSS provides dozens of alternatives. Here are the most useful ones:
Ordered List Numbering Systems
/* Standard numbers: 1, 2, 3... */
ol.numbers {
list-style-type: decimal;
}
/* Leading zeros: 01, 02, 03... */
ol.padded {
list-style-type: decimal-leading-zero;
}
/* Lowercase letters: a, b, c... */
ol.alpha {
list-style-type: lower-alpha;
}
/* Uppercase letters: A, B, C... */
ol.upper-alpha {
list-style-type: upper-alpha;
}
/* Lowercase Roman numerals: i, ii, iii... */
ol.roman {
list-style-type: lower-roman;
}
/* Uppercase Roman numerals: I, II, III... */
ol.upper-roman {
list-style-type: upper-roman;
}
/* Lowercase Greek: α, β, γ... */
ol.greek {
list-style-type: lower-greek;
}
Custom String Markers
One of the most flexible features of list-style-type is the ability to supply a custom string as the marker. You wrap the string in quotes and it appears before each list item. This is perfect for adding emoji markers, arrows, check marks, or any other character.
Using Custom String Markers
/* Arrow marker */
ul.arrows {
list-style-type: "→ ";
}
/* Check mark marker */
ul.checks {
list-style-type: "✓ ";
}
/* Star marker */
ul.stars {
list-style-type: "★ ";
}
/* Dash marker */
ul.dashes {
list-style-type: "– ";
}
"→ " instead of "→") to create visual separation between the marker and the list item text. Without this space, the marker will sit right against the text.The list-style-position Property
The list-style-position property controls where the marker is placed relative to the list item's content box. It accepts two values: outside (the default) and inside.
With outside, the marker sits in the margin area to the left of the content box. This means multi-line text wraps neatly, with subsequent lines aligning under the first line of text rather than under the marker. With inside, the marker is placed inside the content box as if it were the first inline element. Multi-line text wraps under the marker, which can look untidy for longer items but is useful when you need the marker within a bordered or background-colored container.
Marker Position: Outside vs. Inside
/* Default: marker outside the content box */
ul.outside-markers {
list-style-position: outside;
padding-left: 2rem;
}
/* Marker inside the content box */
ul.inside-markers {
list-style-position: inside;
padding-left: 0;
}
/* Inside is useful with borders or backgrounds */
ul.boxed-list {
list-style-position: inside;
padding: 0;
}
ul.boxed-list li {
background-color: var(--bg-light);
border: 1px solid var(--border-light);
padding: 0.75rem 1rem;
margin-bottom: 0.5rem;
border-radius: 4px;
}
The list-style-image Property
The list-style-image property lets you replace the marker with a custom image. You supply a url() pointing to an image file. While this is a quick way to add graphical markers, it has limitations: you cannot control the size of the image, and alignment can be tricky. For most modern projects, using ::marker or background images on the <li> gives you more control.
Using an Image as a List Marker
/* Replace bullets with a custom icon */
ul.custom-icon {
list-style-image: url("/images/icons/arrow-right.svg");
}
/* Fallback: if the image fails to load, disc is used */
ul.with-fallback {
list-style-type: disc; /* fallback */
list-style-image: url("/images/icons/check.svg");
}
list-style-image property takes precedence over list-style-type. If both are set and the image loads successfully, the image is used. If the image fails to load, the browser falls back to the list-style-type value. Always set a meaningful list-style-type as a fallback when using list-style-image.The list-style Shorthand
The list-style shorthand combines list-style-type, list-style-position, and list-style-image into a single declaration. The values can appear in any order, and you can omit any of them. The browser determines which value corresponds to which property based on the value type.
The list-style Shorthand in Action
/* Type and position */
ul {
list-style: square inside;
}
/* Image with fallback type */
ul.icons {
list-style: disc url("/images/bullet.svg") outside;
}
/* Remove all markers (most common shorthand use) */
ul.nav,
ul.clean {
list-style: none;
}
/* Just change the type */
ol.legal {
list-style: upper-roman;
}
The ::marker Pseudo-Element
The ::marker pseudo-element gives you direct styling access to the marker box of a list item. Before ::marker existed, styling the bullet or number required workarounds like hiding the marker and using ::before pseudo-elements. Now you can change the color, size, font, and content of markers directly. This pseudo-element works on any element with display: list-item, as well as on <summary> elements.
Properties You Can Set on ::marker
The ::marker pseudo-element supports a limited but useful set of properties: all font properties, color, text-combine-upright, unicode-bidi, direction, content, and all animation and transition properties. Notably, you cannot set background, padding, border, or width/height on ::marker.
Styling Markers with ::marker
/* Change marker color */
li::marker {
color: var(--primary);
}
/* Larger, bold markers */
ol li::marker {
font-size: 1.2em;
font-weight: 700;
color: var(--primary);
}
/* Custom content in markers */
ul.custom li::marker {
content: "▸ ";
color: var(--primary);
font-size: 1.1em;
}
/* Different markers per item */
li.complete::marker {
content: "✅ ";
}
li.in-progress::marker {
content: "🔄 ";
}
li.pending::marker {
content: "⏳ ";
}
Using content with ::marker
The content property on ::marker lets you override the default marker with any text, including counter values. This is especially powerful on ordered lists where you can combine automatic numbering with custom formatting.
Custom Numbered Markers
/* Numbered with parenthesis: 1) 2) 3) */
ol.paren li::marker {
content: counter(list-item) ") ";
color: var(--primary);
font-weight: bold;
}
/* Numbered with dot and dash: 1 - 2 - 3 - */
ol.dash li::marker {
content: counter(list-item) " – ";
}
/* Step numbering: Step 1: Step 2: Step 3: */
ol.steps li::marker {
content: "Step " counter(list-item) ": ";
font-weight: 600;
color: var(--text-light);
}
list-item counter is automatically maintained by ordered lists, so you do not need to set up counter-reset or counter-increment manually when using it with ::marker. This counter is available on <ol> and <ul> list items by default.CSS Counters
CSS counters are variables maintained by CSS whose values can be incremented, decremented, and displayed using generated content. They are the engine behind automatic numbering in CSS and are far more flexible than the built-in list-item counter. With CSS counters, you can number any element -- not just list items -- and create complex hierarchical numbering systems like "1.1", "1.2.3", and so on.
counter-reset
The counter-reset property creates or resets a counter. It sets the counter to zero (or another specified value) on the element where it is applied. You typically place it on the parent element that contains the items you want to number.
counter-increment
The counter-increment property increases (or decreases) a counter's value. It is placed on each element that should advance the count. By default it increments by 1, but you can specify a different increment value.
counter() and counters() Functions
The counter() function outputs the current value of a named counter. The counters() function is used for nested counters, outputting all values in the counter's nesting stack separated by a string you define.
Basic CSS Counter Setup
/* Reset the counter on the parent */
.numbered-sections {
counter-reset: section;
}
/* Increment for each section heading */
.numbered-sections h2 {
counter-increment: section;
}
/* Display the counter before each heading */
.numbered-sections h2::before {
content: "Section " counter(section) ": ";
color: var(--primary);
font-weight: 700;
}
/* You can reset to a specific value */
.start-at-five {
counter-reset: item 4; /* starts at 5 after first increment */
}
/* Increment by custom amounts */
.even-numbers h3 {
counter-increment: item 2;
}
Nested Counters for Hierarchical Numbering
The real power of CSS counters shines with nested lists. By resetting a counter on each <ol> or <ul>, the browser creates a new instance of that counter for each nesting level. The counters() function then joins all levels with a separator, producing numbering like "1.1", "1.2", "2.1", "2.1.1", and so on.
Hierarchical Numbering with counters()
/* Each ol resets and creates a new counter instance */
ol.hierarchical {
counter-reset: item;
list-style-type: none;
padding-left: 1.5rem;
}
/* Each li increments and displays all nested levels */
ol.hierarchical li {
counter-increment: item;
}
ol.hierarchical li::before {
content: counters(item, ".") " ";
color: var(--primary);
font-weight: 700;
margin-right: 0.5rem;
}
/* HTML structure:
<ol class="hierarchical">
<li>Introduction -- 1
<ol class="hierarchical">
<li>Background -- 1.1
<li>Purpose -- 1.2
</ol>
<li>Methods -- 2
<ol class="hierarchical">
<li>Data Collection -- 2.1
<ol class="hierarchical">
<li>Surveys -- 2.1.1
<li>Interviews -- 2.1.2
</ol>
<li>Analysis -- 2.2
</ol>
</ol>
*/
counters() function takes two required arguments: the counter name and a separator string. It also accepts an optional third argument for the counter style (e.g., counters(item, ".", upper-roman) would produce "I.I", "I.II", etc.). This lets you combine hierarchical structure with any numbering system.Counter Styles in counter()
Both counter() and counters() accept an optional style parameter that changes how the number is displayed. This style parameter accepts the same values as list-style-type.
Counter Display Styles
/* Display as uppercase Roman numerals */
.roman-sections h2::before {
content: counter(section, upper-roman) ". ";
}
/* Display as lowercase letters */
.alpha-steps li::before {
content: counter(step, lower-alpha) ") ";
}
/* Display with leading zeros */
.padded-list li::before {
content: counter(item, decimal-leading-zero) ". ";
}
Building a Horizontal Navigation Bar
One of the most common uses of styled lists is building a horizontal navigation bar. The pattern is straightforward: start with a semantic <nav> element containing a <ul>, remove the default list styling, and use Flexbox to arrange the items horizontally.
Horizontal Navigation from a List
/* HTML Structure:
<nav aria-label="Main navigation">
<ul class="navbar">
<li><a href="/">Home</a></li>
<li><a href="/about">About</a></li>
<li><a href="/services">Services</a></li>
<li><a href="/contact">Contact</a></li>
</ul>
</nav>
*/
.navbar {
list-style: none;
margin: 0;
padding: 0;
display: flex;
gap: 0;
background-color: var(--bg-white);
border-bottom: 2px solid var(--border-light);
}
.navbar li a {
display: block;
padding: 1rem 1.5rem;
text-decoration: none;
color: var(--text-dark);
font-weight: 500;
transition: background-color 0.2s, color 0.2s;
}
.navbar li a:hover,
.navbar li a:focus-visible {
background-color: var(--primary-light);
color: var(--primary);
}
.navbar li a[aria-current="page"] {
color: var(--primary);
border-bottom: 3px solid var(--primary);
}
Dropdown Navigation Menus
Dropdown menus extend the horizontal nav pattern by nesting a second <ul> inside a list item. The nested list is hidden by default and revealed when the parent item is hovered or focused. Proper keyboard accessibility is critical: the dropdown must be reachable via the Tab key, not just mouse hover.
CSS Dropdown Menu
/* HTML Structure:
<ul class="nav-dropdown">
<li>
<a href="/services">Services</a>
<ul class="submenu">
<li><a href="/web-design">Web Design</a></li>
<li><a href="/seo">SEO</a></li>
<li><a href="/marketing">Marketing</a></li>
</ul>
</li>
</ul>
*/
.nav-dropdown {
list-style: none;
margin: 0;
padding: 0;
display: flex;
}
.nav-dropdown > li {
position: relative;
}
.nav-dropdown a {
display: block;
padding: 1rem 1.25rem;
text-decoration: none;
color: var(--text-dark);
white-space: nowrap;
}
/* Hidden submenu */
.submenu {
list-style: none;
margin: 0;
padding: 0.5rem 0;
position: absolute;
top: 100%;
left: 0;
min-width: 200px;
background: var(--bg-white);
border: 1px solid var(--border-light);
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
opacity: 0;
visibility: hidden;
transform: translateY(-8px);
transition: opacity 0.2s, visibility 0.2s, transform 0.2s;
}
/* Show on hover */
.nav-dropdown > li:hover > .submenu,
.nav-dropdown > li:focus-within > .submenu {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.submenu a {
padding: 0.5rem 1.25rem;
}
.submenu a:hover,
.submenu a:focus-visible {
background-color: var(--bg-light);
color: var(--primary);
}
:focus-within on the parent <li> is essential for keyboard accessibility. Without it, users navigating with the Tab key would never be able to reach the dropdown items. The :focus-within pseudo-class keeps the dropdown visible when any child element inside it has focus, enabling full keyboard navigation of the menu.Breadcrumb Navigation
Breadcrumbs show the user's position within a site hierarchy. They are built with an ordered list inside a <nav> element with aria-label="Breadcrumb". CSS separators are added between items using the ::before or ::after pseudo-element, so the HTML stays clean and semantic.
Breadcrumb Navigation Pattern
/* HTML Structure:
<nav aria-label="Breadcrumb">
<ol class="breadcrumb">
<li><a href="/">Home</a></li>
<li><a href="/products">Products</a></li>
<li><a href="/products/laptops">Laptops</a></li>
<li aria-current="page">MacBook Pro</li>
</ol>
</nav>
*/
.breadcrumb {
list-style: none;
margin: 0;
padding: 0.75rem 1rem;
display: flex;
flex-wrap: wrap;
align-items: center;
background-color: var(--bg-light);
border-radius: 4px;
font-size: 0.9rem;
}
/* Add separator between items */
.breadcrumb li + li::before {
content: "/";
margin: 0 0.5rem;
color: var(--text-light);
}
.breadcrumb a {
color: var(--primary);
text-decoration: none;
}
.breadcrumb a:hover {
text-decoration: underline;
}
/* Current page (no link) */
.breadcrumb li[aria-current="page"] {
color: var(--text-light);
font-weight: 500;
}
Sidebar Navigation
Sidebar navigation is a vertical list of links, often with nested sub-sections that expand and collapse. The foundation is a simple styled <ul> with visual indicators for the active item and hover states. Nesting additional lists creates a tree-like structure for multi-level navigation.
Sidebar Navigation with Nested Levels
/* HTML Structure:
<nav class="sidebar-nav" aria-label="Documentation">
<ul>
<li><a href="#" class="active">Getting Started</a></li>
<li>
<a href="#">Components</a>
<ul>
<li><a href="#">Buttons</a></li>
<li><a href="#">Cards</a></li>
<li><a href="#">Modals</a></li>
</ul>
</li>
<li><a href="#">Utilities</a></li>
</ul>
</nav>
*/
.sidebar-nav ul {
list-style: none;
margin: 0;
padding: 0;
}
.sidebar-nav a {
display: block;
padding: 0.6rem 1rem;
text-decoration: none;
color: var(--text-dark);
border-left: 3px solid transparent;
transition: all 0.2s;
}
.sidebar-nav a:hover,
.sidebar-nav a:focus-visible {
background-color: var(--bg-light);
color: var(--primary);
border-left-color: var(--primary-light);
}
.sidebar-nav a.active {
color: var(--primary);
background-color: var(--primary-light);
border-left-color: var(--primary);
font-weight: 600;
}
/* Nested list indentation */
.sidebar-nav ul ul {
padding-left: 1rem;
}
.sidebar-nav ul ul a {
font-size: 0.9rem;
padding: 0.4rem 1rem;
}
Pagination
Pagination controls are another classic list-based UI pattern. The pattern uses an unordered list with each page number as a list item, styled horizontally with Flexbox. Active, disabled, and hover states complete the interaction design.
Pagination Component
/* HTML Structure:
<nav aria-label="Pagination">
<ul class="pagination">
<li><a href="#" aria-label="Previous page">«</a></li>
<li><a href="#">1</a></li>
<li><a href="#" aria-current="page">2</a></li>
<li><a href="#">3</a></li>
<li><span class="ellipsis">...</span></li>
<li><a href="#">12</a></li>
<li><a href="#" aria-label="Next page">»</a></li>
</ul>
</nav>
*/
.pagination {
list-style: none;
margin: 0;
padding: 0;
display: flex;
gap: 4px;
}
.pagination a,
.pagination .ellipsis {
display: flex;
align-items: center;
justify-content: center;
min-width: 2.5rem;
height: 2.5rem;
padding: 0 0.75rem;
text-decoration: none;
color: var(--text-dark);
border: 1px solid var(--border-light);
border-radius: 4px;
font-size: 0.9rem;
transition: all 0.2s;
}
.pagination a:hover,
.pagination a:focus-visible {
background-color: var(--bg-light);
border-color: var(--primary);
color: var(--primary);
}
.pagination a[aria-current="page"] {
background-color: var(--primary);
border-color: var(--primary);
color: white;
font-weight: 600;
}
.pagination .ellipsis {
border: none;
color: var(--text-light);
}
Tag and Chip Lists
Tags (also called chips or badges) are compact labels often displayed as a horizontal list. They are used for categories, skills, filters, or selected items. The list provides semantic grouping while CSS transforms the items into pill-shaped or rounded-rectangle buttons.
Tag/Chip List Component
/* HTML Structure:
<ul class="tag-list" aria-label="Technologies">
<li><a href="#">HTML</a></li>
<li><a href="#">CSS</a></li>
<li><a href="#">JavaScript</a></li>
<li><a href="#">React</a></li>
</ul>
*/
.tag-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.tag-list a {
display: inline-block;
padding: 0.35rem 0.85rem;
text-decoration: none;
font-size: 0.85rem;
color: var(--primary);
background-color: var(--primary-light);
border-radius: 999px;
transition: background-color 0.2s, color 0.2s;
}
.tag-list a:hover,
.tag-list a:focus-visible {
background-color: var(--primary);
color: white;
}
Accessible List and Navigation Patterns
Accessibility is not optional when building navigation from lists. Here are the essential practices every developer must follow:
- Use
<nav>witharia-label-- Wrap navigation lists in a<nav>element with a descriptivearia-label. If the page has multiple<nav>elements, each one must have a unique label so screen reader users can distinguish them (e.g., "Main navigation", "Footer navigation", "Breadcrumb"). - Use
aria-current="page"-- Mark the current page link witharia-current="page"so screen readers announce which link corresponds to the current page. - Keyboard navigation -- Ensure all interactive elements are reachable with the
Tabkey. For dropdown menus, use:focus-withinto keep submenus visible when navigating by keyboard. - Visible focus indicators -- Never remove the focus outline without providing an alternative. Use
:focus-visibleto style focus indicators that appear only during keyboard navigation. - Do not remove list semantics carelessly -- Setting
list-style: nonecauses VoiceOver in Safari to stop announcing elements as a list. To preserve list semantics, addrole="list"to the<ul>when you remove default markers.
Preserving List Semantics in Safari VoiceOver
<!-- Add role="list" when using list-style: none -->
<nav aria-label="Main navigation">
<ul class="navbar" role="list">
<li><a href="/" aria-current="page">Home</a></li>
<li><a href="/about">About</a></li>
<li><a href="/contact">Contact</a></li>
</ul>
</nav>
list-style: none is applied. This is a deliberate design decision by Apple to reduce announcement noise on navigational lists. If you need the list to be announced as a list (such as in content areas where the count matters), add role="list" to the <ul> or <ol> element. However, for navigation menus where the <nav> landmark already provides sufficient context, the missing list semantics are generally acceptable.Practical Example: Multi-Level Documentation Navigation
Let us bring everything together with a comprehensive example that combines counters, nested lists, and navigation patterns into a documentation sidebar with hierarchical numbering.
Complete Documentation Navigation
/* HTML Structure:
<nav class="docs-nav" aria-label="Documentation">
<ol>
<li>
<a href="#intro">Introduction</a>
<ol>
<li><a href="#what">What is CSS?</a></li>
<li><a href="#why">Why Learn CSS?</a></li>
</ol>
</li>
<li>
<a href="#selectors">Selectors</a>
<ol>
<li><a href="#basic">Basic Selectors</a></li>
<li><a href="#combinator">Combinators</a></li>
</ol>
</li>
</ol>
</nav>
*/
.docs-nav ol {
counter-reset: doc-section;
list-style: none;
margin: 0;
padding: 0;
}
.docs-nav > ol > li {
counter-increment: doc-section;
margin-bottom: 0.5rem;
}
.docs-nav > ol > li > a {
display: block;
padding: 0.6rem 1rem;
font-weight: 700;
color: var(--text-dark);
text-decoration: none;
border-radius: 4px;
}
.docs-nav > ol > li > a::before {
content: counter(doc-section) ". ";
color: var(--primary);
}
.docs-nav > ol > li > a:hover {
background-color: var(--bg-light);
}
/* Nested level */
.docs-nav ol ol {
padding-left: 1.5rem;
margin-top: 0.25rem;
}
.docs-nav ol ol li {
counter-increment: doc-section;
}
.docs-nav ol ol a {
display: block;
padding: 0.35rem 0.75rem;
font-size: 0.9rem;
color: var(--text-light);
text-decoration: none;
border-radius: 3px;
}
.docs-nav ol ol a::before {
content: counters(doc-section, ".") " ";
color: var(--primary);
font-weight: 600;
}
.docs-nav ol ol a:hover {
background-color: var(--bg-light);
color: var(--text-dark);
}
Practice Exercise
Build a complete website navigation system that includes all of the following components: First, create a horizontal navigation bar with five links, a hover effect, and an active page indicator using aria-current="page". Second, add a dropdown menu to one of the navigation items with at least three sub-links, making sure the dropdown is accessible via both mouse hover and keyboard focus using :focus-within. Third, build a breadcrumb trail with at least four levels (Home, Category, Subcategory, Current Page) using CSS-generated separators. Fourth, create a sidebar navigation with two levels of nesting, using CSS counters to produce hierarchical numbering (1.1, 1.2, 2.1, etc.). Fifth, add a pagination component with previous/next buttons, numbered pages, and an ellipsis for skipped pages. Finally, include a tag list with at least six pill-shaped tag links. Ensure every navigation component is wrapped in a <nav> with a unique aria-label, all focus states are visible, and the list semantics are preserved where needed with role="list".