CSS Breadcrumb Navigation
Navigation breadcrumbs with ::before arrow separators using CSS content.
A breadcrumb trail is an ordered list of the pages between the site root and where the reader is now. The markup is a <nav> holding an <ol>, and the separators between items are not in the markup at all. li + li::before selects every list item that follows another one and generates the character in front of it, which keeps the HTML free of slashes that a screen reader would otherwise read aloud.
The four trails in the demo above share identical markup and differ only in the content value and a few pixels of spacing. Two more versions follow: an interlocking arrow style built from CSS border triangles, and a trail that scrolls sideways when the path is deeper than the space available.
Four separator styles, one set of markup
HTML
<nav aria-label="Breadcrumb">
<ol>
<li><a href="#">Home</a></li>
<li><a href="#">Components</a></li>
<li><a href="#">Navigation</a></li>
<li>Breadcrumb</li>
</ol>
</nav>
<!-- Same markup, different separator -->
<nav aria-label="Breadcrumb" class="bc-arrow">
<ol>
<li><a href="#">Dashboard</a></li>
<li><a href="#">Settings</a></li>
<li>Profile</li>
</ol>
</nav>
<nav aria-label="Breadcrumb" class="bc-chevron">
<ol>
<li><a href="#">Products</a></li>
<li><a href="#">Electronics</a></li>
<li>Laptops</li>
</ol>
</nav>
<nav aria-label="Breadcrumb" class="bc-pill">
<ol>
<li><a href="#">Blog</a></li>
<li><a href="#">CSS Tips</a></li>
<li>Breadcrumbs</li>
</ol>
</nav>
CSS
nav ol {
list-style: none;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0;
margin: 0 0 1.5rem;
padding: 0;
}
nav li {
display: flex;
align-items: center;
font-size: .9rem;
}
/* The separator sits on every item that follows another one */
nav li + li::before {
content: "/";
color: #88888f;
margin: 0 .5rem;
}
nav li:last-child {
color: #f0f0f0;
font-weight: 500;
}
nav a {
color: #88888f;
text-decoration: none;
transition: color .15s;
}
nav a:hover {
color: #b8ff57;
}
/* Arrow variant */
.bc-arrow li + li::before {
content: "›";
font-size: 1.1rem;
line-height: 1;
}
/* Chevron drawn from two borders instead of a character */
.bc-chevron li + li::before {
content: "";
display: inline-block;
width: 6px;
height: 6px;
border-top: 1.5px solid #88888f;
border-right: 1.5px solid #88888f;
transform: rotate(45deg);
margin: 0 .6rem;
}
/* Pill variant drops the separator and spaces the items instead */
.bc-pill li {
background: #1c1c1e;
padding: .25rem .75rem;
border-radius: 99px;
font-size: .8rem;
}
.bc-pill li + li::before {
display: none;
}
.bc-pill li + li {
margin-left: .35rem;
}
.bc-pill li:last-child {
background: rgba(184, 255, 87, .1);
color: #b8ff57;
}
Other ways to build it
Interlocking arrows drawn from borders
Each crumb has a point on its right and a notch cut into its left, and neither is an image or a glyph. A zero-size box with a 15px transparent border and one side colored paints a solid triangle, which is how CSS drew shapes for years before clip-path. The right hand gap between crumbs is exactly the width of the triangle, so the point fills it and the chain reads as continuous without any of the pieces overlapping or needing a stacking order.
HTML
<nav class="bc-way" aria-label="Breadcrumb">
<ol>
<li><a href="#">Home</a></li>
<li><a href="#">Store</a></li>
<li><a href="#">Audio</a></li>
<li aria-current="page">Headphones</li>
</ol>
</nav>
CSS
/* the crumb height and the border width have to match:
a 15px border makes a 30px triangle */
.bc-way li {
position: relative;
display: flex;
align-items: center;
height: 30px;
margin-right: 15px;
padding: 0 .7rem 0 1.4rem;
background: #1c1c1e;
font-size: .8rem;
}
/* the point, sitting in the gap to the right */
.bc-way li::after {
content: "";
position: absolute;
left: 100%;
top: 0;
border: 15px solid transparent;
border-left-color: #1c1c1e;
}
/* the notch, cut in the page background color */
.bc-way li::before {
content: "";
position: absolute;
left: 0;
top: 0;
border: 15px solid transparent;
border-left-color: #141415;
}
.bc-way li:first-child::before { display: none; }
.bc-way li:last-child::after { display: none; }
.bc-way li:last-child {
margin-right: 0;
background: rgba(184, 255, 87, .1);
color: #b8ff57;
}
A deep trail that scrolls instead of wrapping
Six levels of hierarchy will not fit across a phone, and wrapping to three lines pushes the page content down. The alternative is one line with flex-wrap: nowrap and overflow-x: auto. On its own that opens showing the site root, which is the least useful end. direction: rtl on the scroll container flips where the scroll starts, flex-direction: row-reverse on the list puts the crumbs back into reading order, and direction: ltr on each item keeps the labels and separators from mirroring. The trail opens on the current page and scrolls back towards Home. Drag it sideways to see the rest.
HTML
<div class="bc-shelf">
<div class="bc-scroll">
<nav aria-label="Breadcrumb">
<ol>
<li><a href="#">Home</a></li>
<li><a href="#">Documentation</a></li>
<!-- ...four more levels... -->
<li aria-current="page">Separators</li>
</ol>
</nav>
</div>
<span class="bc-edge"></span>
</div>
CSS
.bc-shelf {
position: relative;
max-width: 320px;
}
/* rtl on the scroller starts it at the far end, which is
where the current page sits */
.bc-scroll {
overflow-x: auto;
direction: rtl;
}
/* row-reverse inside an rtl container runs left to right
again, so the crumbs keep reading order while the scroll
origin stays flipped; max-content stops the list
shrinking to the width of the scroller */
.bc-scroll ol {
direction: rtl;
flex-direction: row-reverse;
flex-wrap: nowrap;
white-space: nowrap;
width: max-content;
}
/* back to ltr per item, or labels and separators mirror */
.bc-scroll li {
direction: ltr;
flex: 0 0 auto;
}
/* a fade at the start, so the trail reads as cut off
rather than as beginning at Documentation */
.bc-edge {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 36px;
pointer-events: none;
background: linear-gradient(to right, #141415, transparent);
}
How it works
Breadcrumbs are an <ol> list styled as a horizontal trail. The separator between items is injected using li + li::before, where the adjacent sibling combinator selects every <li> that immediately follows another. The content property can be any character, string, or even an SVG. No JavaScript or extra HTML needed for the separators.
The choice of li + li::before over li::after matters. Using ::after on every item puts a separator after the last one too, which then has to be removed with a second rule. Using ::before on items that follow another item produces exactly one separator between each pair and none at either end, from a single rule. + is the adjacent sibling combinator, so it only matches an item directly after another, which is what makes it precise here rather than the looser ~.
Generated content is not text in the document. It cannot be selected, it is not copied when the reader copies the trail, and browsers vary in whether they expose it to assistive technology at all. For a decorative slash that variation is the desired outcome, since a screen reader announcing "Home slash Components slash Navigation" adds nothing. It also means you cannot put anything meaningful in a separator, because there is no guarantee it will be read.
The chevron in the third trail is drawn rather than typed. A zero size box with two borders and transform: rotate(45deg) gives a chevron that scales with the font size and needs no glyph, which sidesteps the fact that characters like the guillemet render at different weights across fonts. The arrow version below uses the same trick at a larger scale: a 15px transparent border with one side colored produces a solid triangle, which is how CSS drew shapes long before clip-path existed.
Only the separator is styling. The trail's meaning belongs in the markup: <nav aria-label="Breadcrumb"> so the region can be found and skipped, an <ol> because the order carries information, and aria-current="page" on the last item. Pair that with BreadcrumbList structured data, which is what search engines read to show a path instead of a raw URL under a result. The visual trail and the structured data are separate declarations of the same fact, and they should be generated from the same source so they cannot drift apart.
A breadcrumb answers a different question from a pagination bar or a tab strip. Those two say which of several equivalent views is showing. A breadcrumb says where this page sits in a hierarchy, so it is a path rather than a selection, and the last item is a label rather than a control.
CSS properties used
content- Generates the separator on
::before. Takes a string, a character, anattr()value, or nothing at all when the shape is drawn from borders. +- The adjacent sibling combinator.
li + limatches every item after the first, which is exactly the set that needs a separator in front of it. ::before- Where the separator lives. Putting it on the following item, rather than
::afteron the preceding one, avoids a trailing separator at the end of the trail. transformrotate(45deg)turns a two-sided box into a chevron, and the same border trick at a larger size makes the solid arrow in the wayfinder variant.flex-wrapwraplets a deep trail fall onto a second line.nowrapwithoverflow-x: autokeeps it on one line and scrolls instead.directionrtlon a scroll container starts it scrolled to the far end, which is how the scrolling trail below opens showing the current page rather than the site root.
Browser support
| Feature | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
content | 4 | 2 | 3.1 | 12 |
+ combinator | 4 | 2 | 3.1 | 12 |
flexbox | 21 | 28 | 6.1 | 12 |
transform | 4 | 3.5 | 3.1 | 12 |
logical properties | 89 | 66 | 15 | 89 |
Figures come from Can I Use for CSS generated content, CSS 2.1 selectors, the Flexible Box Layout module, 2D transforms, and CSS logical properties. Everything the separators need has been supported for well over a decade. The logical properties row applies only if the trail has to mirror in right to left languages, where margin-inline-start and padding-inline flip with the writing mode and their physical equivalents do not. direction: rtl on a scroll container has no Can I Use entry of its own because it is part of CSS 2.1, and it has behaved consistently for as long as overflow has.
Accessibility notes
Wrap the list in <nav aria-label="Breadcrumb">. Without a name, a screen reader lists it among every other navigation region on the page with nothing to tell them apart. Use an <ol> rather than a <ul>, because the sequence is the information. Mark the final item with aria-current="page" so a reader knows the trail has ended at the page they are on rather than at another link.
Leave the separators as generated content and do not put them in the HTML. Browsers disagree about whether generated content reaches the accessibility tree, and a slash between every crumb is noise in either case. If a separator ever needs to be announced, it does not belong in content.
The last crumb is usually not a link, since linking to the current page gives a reader nothing. Style it as text with enough contrast to read as the endpoint of the trail rather than as a disabled link. Where it must stay a link for consistency, keep aria-current="page" on it so the state is announced instead of inferred from color.
A trail that scrolls sideways still has to be reachable by keyboard. The links inside it are focusable, and a browser scrolls a focused element into view automatically, so tabbing through works. Do not set tabindex="-1" on the scroll container to tidy it up, or the reader loses the ability to scroll it without a pointer.
What you can build with it
- Documentation hierarchies. Section, page and heading, so a reader arriving from a search result can see how deep in the tree they landed.
- Ecommerce category paths. Department to category to product. This is the case where
BreadcrumbListmarkup pays for itself, because search results show the path instead of the URL. - Multi step checkout. A read-only trail of the steps already completed, sitting alongside a multi step form rather than replacing its controls.
- File and folder browsers. A deep path that needs the scrolling variant, since the segment the reader cares about is the last one.
- Admin record pages. Account to project to record, where the crumbs are the fastest route back up one level without hunting in a sidebar.
Mistakes worth avoiding
- Writing the separators into the HTML. They become selectable text, they are copied along with the crumb labels, and a screen reader may read every one of them aloud.
- Using
li::afterfor the separator and forgetting the:last-childrule that removes the trailing one. The trail ends in a dangling slash, which reads as a truncated path. - Letting a deep trail wrap onto three lines on a phone. Either allow one wrap and truncate the middle crumbs, or switch to a single line that scrolls sideways.
- Making the last crumb a link to the page the reader is already on. It is a dead control, and without
aria-currentthere is nothing to say the trail has ended. - Shipping the visual trail without
BreadcrumbListstructured data. The hierarchy is only in the CSS at that point, and search results show the raw URL instead of the path.
Frequently asked questions
How do I add separators between breadcrumbs in CSS?
li + li::before { content: "/" }. The adjacent sibling combinator matches every item that follows another, so each pair gets one separator and neither end gets a stray one. Nothing goes in the HTML.Should breadcrumbs be a ul or an ol?
<ol>. The order of a breadcrumb is the meaning of it, and an ordered list is what says so. Wrap it in <nav aria-label="Breadcrumb"> so the region has a name.Do breadcrumbs help SEO?
BreadcrumbList structured data lets a search result show the site path in place of the URL, which describes where the page sits. The visual trail on its own is a usability feature, and the two should be generated from the same data so they cannot disagree.How do I handle a breadcrumb that is too long for mobile?
flex-wrap: nowrap and overflow-x: auto, and start the container scrolled to the end so the current page is what shows. Or collapse the middle crumbs to an ellipsis and keep the first and last, which is shorter but hides the path.Can I use an SVG as a breadcrumb separator?
content: url(chevron.svg) works, as does a data URI, and background-image on the pseudo-element is the other route. Both cost a paint of an external resource where a drawn chevron costs nothing, so the border-and-rotate approach is usually the better trade.