Style a scrollbar with CSS

Two standard properties set the colour and width of a scrollbar. The older ::-webkit-scrollbar pseudo-elements give more control, and in Chromium the standard ones win when both are set.

A scrollbar is styled with two standard CSS properties: scrollbar-color sets the thumb and track colours, and scrollbar-width picks auto, thin or none.

The older ::-webkit-scrollbar pseudo-elements add pixel widths and rounded thumbs. In Chromium, if a standard property is set, the webkit rules are ignored.

Try it first. Pick colours and a width, then switch between the two methods and scroll the box.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Scrollbar styler</title>
<style>
  body { margin: 0; padding: 16px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .wrap { display: flex; flex-wrap: wrap; gap: 16px; }
  .controls { display: grid; gap: 10px; flex: 1 1 200px; font-size: 14px; }
  .controls label { display: flex; justify-content: space-between; align-items: center; gap: 8px; }
  .controls fieldset { border: 1px solid #d5d9e0; border-radius: 8px; padding: 6px 10px; margin: 0; }
  .box {
    flex: 1 1 220px; height: 200px;
    overflow-y: auto;            /* the box scrolls when its content is taller */
    background: #fff; border: 1px solid #d5d9e0; border-radius: 10px; padding: 0 14px;
  }
  .box p { line-height: 1.5; }
  pre { background: #1d2330; color: #e5e7eb; border-radius: 10px; padding: 12px; font-size: 12.5px; overflow-x: auto; margin: 16px 0 0; }
</style>
<!-- the scrollbar rules the controls write -->
<style id="live"></style>
</head>
<body>
<div class="wrap">
  <div class="controls">
    <label>Thumb colour <input type="color" id="thumb" value="#2563eb"></label>
    <label>Track colour <input type="color" id="track" value="#e5e7eb"></label>
    <label>Width
      <select id="width">
        <option value="auto">auto</option>
        <option value="thin" selected>thin</option>
        <option value="none">none</option>
      </select>
    </label>
    <fieldset>
      <legend>Apply to the box</legend>
      <label><span>Standard properties</span><input type="radio" name="mode" value="std" checked></label>
      <label><span>::-webkit-scrollbar only</span><input type="radio" name="mode" value="wk"></label>
    </fieldset>
  </div>
  <div class="box" id="box">
    <p><b>Scroll this box.</b> The scrollbar on the right uses the colours and width you pick.</p>
    <p>With "Standard properties", scrollbar-color and scrollbar-width style it.</p>
    <p>With "::-webkit-scrollbar only", the thumb is rounded and its width is a pixel value.</p>
    <p>Firefox reads only the standard properties.</p>
    <p>Chromium browsers read both, and when a standard property is set they ignore the webkit rules.</p>
    <p>End of the box.</p>
  </div>
</div>
<pre id="out"></pre>

<script>
  const $ = (id) => document.getElementById(id);
  const px = { auto: 14, thin: 8 };  // pixel width used for the webkit version

  function update() {
    const thumb = $('thumb').value, track = $('track').value, width = $('width').value;
    const standard =
`.box {
  scrollbar-color: ${thumb} ${track}; /* thumb, then track */
  scrollbar-width: ${width};
}`;
    const webkit = width === 'none'
? `.box::-webkit-scrollbar { display: none; }`
: `.box::-webkit-scrollbar { width: ${px[width]}px; }
.box::-webkit-scrollbar-track { background: ${track}; }
.box::-webkit-scrollbar-thumb {
  background: ${thumb};
  border-radius: 99px;
}`;
    const mode = document.querySelector('input[name=mode]:checked').value;
    $('live').textContent = mode === 'std' ? standard : webkit;
    $('out').textContent = '/* Standard */\n' + standard + '\n\n/* Fallback: ::-webkit-scrollbar */\n' + webkit;
  }

  document.querySelectorAll('input, select').forEach((el) => el.addEventListener('input', update));
  update();
</script>
</body>
</html>
The same colours applied two ways. The code below the box is what the controls write.

The short version, for a box that scrolls:

.box {
  overflow-y: auto;
  scrollbar-color: #2563eb #e5e7eb; /* thumb, then track */
  scrollbar-width: thin;
}

scrollbar-color and scrollbar-width

These two properties are the standard way. They go on the element that scrolls, not on a child.

The parts of a scrollbar, and which property controls each one.
The parts of a scrollbar, and which property controls each one.
  • scrollbar-color takes two colours: the thumb you drag, then the track behind it. transparent works for the track.
  • scrollbar-width takes a keyword only: auto (the normal bar), thin (a narrower bar), or none (no bar, still scrolls).

There is no pixel value and no rounded corner setting. The browser draws the bar, and you choose its colours and one of three sizes. That limit is the price of working the same in Firefox and Chromium.

::-webkit-scrollbar for pixel widths and rounded thumbs

The pseudo-elements came first and give more control. Each part of the bar is its own element you can style with ordinary CSS:

.box::-webkit-scrollbar { width: 10px; height: 10px; } /* height is for horizontal bars */
.box::-webkit-scrollbar-track { background: #e5e7eb; }
.box::-webkit-scrollbar-thumb {
  background: #2563eb;
  border-radius: 99px;
}
.box::-webkit-scrollbar-thumb:hover { background: #1d4ed8; }

Firefox does not support ::-webkit-scrollbar at all. Chromium browsers and Safari do. So a site that only uses the pseudo-elements shows default bars in Firefox.

Which one the browser uses

Chromium supports both methods, and it does not mix them. The table below is the bar width measured on a test box in Chromium 147 (box width minus inner width):

Rules on the box Bar width seen
No scrollbar rules 15px (default)
::-webkit-scrollbar { width: 30px } 30px
Same, plus scrollbar-width: thin 10px
Same, plus scrollbar-color only 15px
Same, plus scrollbar-width: auto 30px
:root { scrollbar-color: ... } and the webkit rule on the box 15px
In Chromium, any non-auto standard value switches off the webkit rules for that element.
In Chromium, any non-auto standard value switches off the webkit rules for that element.

The last row is the one that catches people. scrollbar-color is inherited, so setting it once on :root or html turns off every ::-webkit-scrollbar rule on the page. scrollbar-width is not inherited, so it only affects the element it is on.

To get the webkit look where the pseudo-elements work and the standard look elsewhere, wrap the standard rules in a support check. In the same Chromium test this kept the 30px webkit bar:

@supports not selector(::-webkit-scrollbar) {
  .box { scrollbar-color: #2563eb #e5e7eb; scrollbar-width: thin; }
}

The page scrollbar: html, not body

The main page scrollbar belongs to the viewport.

In the same Chromium test, scrollbar-color and scrollbar-width on html changed the page bar, and the same rules on body did nothing to it. body::-webkit-scrollbar did style the page bar, so older snippets that use it still work there.

Put page-level scrollbar rules on html, and inner rules on the element that actually has overflow.

Hide a scrollbar but keep scrolling

scrollbar-width: none removes the bar and keeps the scrolling. Add the webkit rule for browsers that only know the pseudo-element:

.strip {
  overflow-x: auto;
  scrollbar-width: none;
}
.strip::-webkit-scrollbar { display: none; }
Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Default, thin, hidden scrollbars and scrollbar-gutter</title>
<style>
  body { margin: 0; padding: 16px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; font-size: 14px; }
  .row { display: grid; grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); gap: 10px; }
  h3 { font-size: 13px; margin: 0 0 6px; font-family: ui-monospace, Consolas, monospace; }
  .box {
    height: 140px; overflow-y: auto;
    background: #fff; border: 1px solid #d5d9e0; border-radius: 8px; padding: 0 10px;
  }
  .box p { margin: 10px 0; }
  .thin { scrollbar-width: thin; }
  .hidden { scrollbar-width: none; }                 /* standard */
  .hidden::-webkit-scrollbar { display: none; }     /* ::-webkit-scrollbar fallback */

  .gutter-test { margin-top: 18px; }
  #list { height: 150px; overflow-y: auto; background: #fff; border: 1px solid #d5d9e0; border-radius: 8px; }
  #list.stable { scrollbar-gutter: stable; }         /* keep room for the bar even before it appears */
  #list div { margin: 6px; padding: 6px; background: #dbeafe; border-radius: 6px; text-align: center; }
  .bar { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin: 8px 0; }
  button { font: inherit; padding: 6px 12px; border-radius: 6px; border: 1px solid #9aa3b2; background: #fff; cursor: pointer; }
  output { font-family: ui-monospace, Consolas, monospace; }
</style>
</head>
<body>
<div class="row">
  <div><h3>default</h3><div class="box"><p>1. Scroll me.</p><p>2</p><p>3</p><p>4</p><p>5</p><p>6. End.</p></div></div>
  <div><h3>thin</h3><div class="box thin"><p>1. Scroll me.</p><p>2</p><p>3</p><p>4</p><p>5</p><p>6. End.</p></div></div>
  <div><h3>none</h3><div class="box hidden" tabindex="0"><p>1. Scroll me.</p><p>2</p><p>3</p><p>4</p><p>5</p><p>6. End.</p></div></div>
</div>

<div class="gutter-test">
  <div class="bar">
    <button id="add">Add a row</button>
    <button id="reset">Reset</button>
    <label><input type="checkbox" id="stable"> scrollbar-gutter: stable</label>
  </div>
  <div id="list"></div>
  <p>Row width: <output id="w"></output></p>
</div>

<script>
  const list = document.getElementById('list');
  const w = document.getElementById('w');

  // show the inner width, so the shift is visible as a number too
  const measure = () => { w.textContent = list.clientWidth + 'px'; };

  function reset() {
    list.innerHTML = '<div>Row 1</div><div>Row 2</div><div>Row 3</div>';
    measure();
  }

  document.getElementById('add').addEventListener('click', () => {
    const row = document.createElement('div');
    row.textContent = 'Row ' + (list.children.length + 1);
    list.append(row);
    measure();
  });
  document.getElementById('reset').addEventListener('click', reset);
  document.getElementById('stable').addEventListener('change', (e) => {
    list.classList.toggle('stable', e.target.checked);
    measure();
  });
  reset();
</script>
</body>
</html>
Top: default, thin and hidden bars on the same content. Below: add rows with and without scrollbar-gutter.

Hiding has a cost. The bar is the only built-in sign that a box holds more than it shows. Without it:

  • People may think the content ends at the edge.
  • On a sideways strip, a mouse user with an ordinary vertical wheel has no bar to drag.
  • Keyboard users need to reach the box. Add tabindex="0" so it can take focus and scroll with the arrow keys.

If the bar is only too heavy, thin with a pale track is usually enough. If you do hide it, show the overflow some other way: a cut off last item, a fade at the edge, or arrows.

Stop layout shift with scrollbar-gutter

With overflow: auto, a classic bar appears only when the content overflows. When it arrives, it takes its width from the content box, so everything inside gets narrower and text can re-wrap.

scrollbar-gutter: stable keeps the space before the bar is needed.
scrollbar-gutter: stable keeps the space before the bar is needed.
.list {
  overflow-y: auto;
  scrollbar-gutter: stable;
}

In the demo above, the row width drops by the bar width when the eighth row arrives. Tick the checkbox and the width stays the same from the start.

scrollbar-gutter: stable both-edges reserves the same space on the opposite side too, so centred content stays centred.

The same line on html stops the page from jumping sideways when you move between a short page and a long one.

overflow: auto vs scroll, and overlay scrollbars

No scrollbar shows up until the element has a limited size and an overflow value that scrolls.

Value Content fits Content overflows
visible (default) No bar Content spills out, no bar on the element
hidden No bar Clipped, no bar, scripts can still scroll it
auto No bar Bar appears
scroll Bar and empty track shown Bar appears

Use auto almost everywhere. scroll keeps a classic bar visible even when there is nothing to scroll, which in the test above measured the same 15px as a real bar.

Some systems use overlay scrollbars instead of classic ones. Phones do, and macOS does depending on its Show scroll bars setting. Overlay bars float over the content and fade in while you scroll.

They take no layout space, so scrollbar-gutter has nothing to reserve and a width change moves nothing. When you test on a Mac, set Show scroll bars to Always to see the classic behaviour.

For tables that are wider than the screen, scrolling an HTML table covers the wrapper and the hints that tell readers there is more.

A finished example: chat window and photo strip

Both scrolling areas share one .scroll class with a thin bar and a transparent track. The chat list also keeps its gutter, and the photo strip snaps to whole cards.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Chat window and image strip with styled scrollbars</title>
<style>
  body { margin: 0; padding: 16px; font-family: system-ui, sans-serif; background: #eef1f5; color: #1d2330; font-size: 14px; }

  /* one scrollbar style for every scrolling area on the page */
  .scroll {
    scrollbar-width: thin;
    scrollbar-color: #94a3b8 transparent;   /* thumb, track */
  }

  /* chat window */
  .chat { max-width: 420px; background: #fff; border-radius: 14px; box-shadow: 0 4px 16px rgba(0,0,0,.08); }
  .messages {
    height: 220px; overflow-y: auto; padding: 12px;
    display: flex; flex-direction: column; gap: 8px;
    scrollbar-gutter: stable;               /* bubbles do not shift when the bar appears */
  }
  .msg { max-width: 75%; padding: 8px 12px; border-radius: 14px; background: #f1f5f9; align-self: flex-start; }
  .msg.me { background: #2563eb; color: #fff; align-self: flex-end; }
  form { display: flex; gap: 8px; padding: 10px; border-top: 1px solid #e5e7eb; }
  input { flex: 1; min-width: 0; font: inherit; padding: 8px 10px; border: 1px solid #d5d9e0; border-radius: 8px; }
  button { font: inherit; padding: 8px 14px; border: 0; border-radius: 8px; background: #2563eb; color: #fff; cursor: pointer; }

  /* horizontal image strip */
  h3 { margin: 20px 0 8px; font-size: 15px; }
  .strip {
    display: flex; gap: 10px; overflow-x: auto; padding-bottom: 10px;
    scroll-snap-type: x mandatory;          /* stop on a whole card */
  }
  .strip figure {
    flex: 0 0 160px; height: 110px; margin: 0; border-radius: 10px;
    scroll-snap-align: start;
    display: grid; place-items: end start; padding: 8px; box-sizing: border-box;
    color: #fff; font-weight: 600; text-shadow: 0 1px 3px rgba(0,0,0,.4);
  }
</style>
</head>
<body>
<div class="chat">
  <div class="messages scroll" id="messages" tabindex="0" aria-label="Messages">
    <div class="msg">Hi! Is the order ready?</div>
    <div class="msg me">Packing it now.</div>
    <div class="msg">Great, thanks.</div>
    <div class="msg me">It ships this afternoon.</div>
    <div class="msg">Can you send the tracking number?</div>
    <div class="msg me">Sure, as soon as it is out.</div>
  </div>
  <form id="form">
    <input id="text" placeholder="Type a message" autocomplete="off">
    <button>Send</button>
  </form>
</div>

<h3>Photos</h3>
<div class="strip scroll" tabindex="0" aria-label="Photos">
  <figure style="background:linear-gradient(135deg,#f97316,#db2777)">Sunset</figure>
  <figure style="background:linear-gradient(135deg,#0ea5e9,#22c55e)">Coast</figure>
  <figure style="background:linear-gradient(135deg,#6366f1,#0ea5e9)">Lake</figure>
  <figure style="background:linear-gradient(135deg,#65a30d,#15803d)">Forest</figure>
  <figure style="background:linear-gradient(135deg,#a855f7,#6366f1)">Night</figure>
  <figure style="background:linear-gradient(135deg,#eab308,#f97316)">Desert</figure>
</div>

<script>
  const messages = document.getElementById('messages');
  const text = document.getElementById('text');

  // start at the newest message
  messages.scrollTop = messages.scrollHeight;

  document.getElementById('form').addEventListener('submit', (e) => {
    e.preventDefault();
    if (!text.value.trim()) return;
    const msg = document.createElement('div');
    msg.className = 'msg me';
    msg.textContent = text.value;
    messages.append(msg);
    text.value = '';
    messages.scrollTop = messages.scrollHeight;  // keep the newest message in view
  });
</script>
</body>
</html>
Send a message and the list scrolls to it. Swipe or scroll the photos and each one snaps into place.
  • Newest message in view: after appending, set scrollTop to scrollHeight.
  • Snap to cards: scroll-snap-type: x mandatory on the strip and scroll-snap-align: start on each card. The HTML carousel guide builds a full slider on the same idea.
  • Keyboard: both areas have tabindex="0" and an aria-label, so they can be focused and scrolled with the arrow keys.

When it does not work

What you see Cause Fix
::-webkit-scrollbar width and radius ignored in Chrome or Edge scrollbar-color or scrollbar-width is set on the element, or scrollbar-color is inherited from :root Remove the standard rule, or wrap it in @supports not selector(::-webkit-scrollbar)
Page scrollbar colour does not change The rule is on body Put scrollbar-color on html
No scrollbar at all No overflow, no fixed size, or the content fits Set a height or width and overflow: auto, then add content
Bar stays thin or hidden until you scroll, width change has no effect Overlay scrollbars (phone, or macOS setting) Expected. Test with macOS Show scroll bars: Always
Styled in Chrome, plain in Firefox Only ::-webkit-scrollbar was used Add scrollbar-color and scrollbar-width
People miss the rest of the content The bar is hidden Use thin instead, or add a visible hint and tabindex="0"
Content jumps sideways when it grows The bar takes space when it appears scrollbar-gutter: stable

Scrollbars look different on every system, so a screenshot only shows one of them. A link lets each person see the bar their own browser and settings draw.

Paste the page into a NOS document and choose Create share link. HTML to link walks through it.

The page renders as written and its scripts run, so people can scroll the boxes and send chat messages themselves. If you change the code later, the same link shows the new version.

Questions people ask

How do I change the scrollbar colour in CSS?

Set scrollbar-color on the element that scrolls, with two colours: the thumb first, then the track. For example scrollbar-color: #2563eb #e5e7eb. For the page scrollbar, put it on html, not on body.

Why are my ::-webkit-scrollbar styles ignored?

In Chromium, once scrollbar-color or scrollbar-width is set to something other than auto on an element, its ::-webkit-scrollbar rules stop applying. scrollbar-color is inherited, so a rule on :root or html switches off the webkit styles on every element below it.

Can I set a scrollbar width in pixels?

Not with the standard property. scrollbar-width only accepts auto, thin and none. A pixel width needs ::-webkit-scrollbar { width: 10px; }, which Firefox does not support.

How do I hide the scrollbar but keep scrolling?

Use scrollbar-width: none, plus ::-webkit-scrollbar { display: none; } for browsers that only know the pseudo-element. The area still scrolls with a wheel, a finger or the keyboard, but nothing on screen tells people that it scrolls.

What does scrollbar-gutter: stable do?

It keeps the space for a classic scrollbar even while the content fits. When the content grows and the bar appears, the content does not get narrower, so nothing jumps sideways.

Keep reading