Build a Scrollbar That Still Scrolls

Build a CSS scrollbar that preserves wheel, keyboard, and touch scrolling.

Introduction

30 Second Summary

A custom-looking scrollbar can make a page feel finished until it ignores the mouse wheel or keyboard. That small visual detail becomes a real problem when the content stops moving.

In this project, you will build a custom scrollbar in a single HTML file using CSS and JavaScript. The browser keeps control of wheel, keyboard, and touch scrolling throughout.

What You'll Build

Your finished page shows a custom thumb staying beside the content as it follows every scroll from top to bottom.

By the end of this project, you'll have:

  • An anchored scrollbar track that remains pinned to its container when the window changes size.
  • A responsive scrollbar thumb whose travel distance and size stay matched to the content.
  • Resilient scrolling through the wheel, keyboard, touch, or thumb dragging, with the native scrollbar returning when browser support is missing.
  • Secret Mission: Adapt the same pattern into a horizontal scroller with a custom horizontal thumb.

Are there any prerequisites?

You only need a text editor plus a current version of Chrome or Safari. The project runs locally with no account, build step, or installation.

Before We Start

This is your moment to lock in what you are building before the hands-on work begins. You will create a custom scrollbar that preserves the browser's built-in scrolling behavior.

Create a Scrollable Container

A custom scrollbar can look polished while quietly removing the browser behavior people rely on. Wheel input and keyboard controls must keep moving the real content.

You will create a standalone HTML page with a focusable scroll container. CSS will hide the native scrollbar only when the later enhancements are available.

In this step, get ready to:
  • Create a local page with enough content to overflow.
  • Hide the native scrollbar behind a feature check.
  • Verify wheel input and keyboard input still scroll the content.
Build the overflow container

A fixed-height element becomes scrollable when its content is taller than the available space. The browser still owns the scrolling behavior.

  • Create index.html on your Desktop with your code editor's new-file control.
  • Replace everything in index.html with the following code:
<!doctype html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Custom Scrollbar</title>
  <style>
    /* Frame the demo and keep the page itself still */
    * { box-sizing: border-box; }
    body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: #111827; color: #e5e7eb; font-family: system-ui, sans-serif; }
    /* Turn the fixed-height region into the page's scroll container */
    .scroll-frame { width: min(32rem, calc(100vw - 2rem)); height: 24rem; overflow-y: auto; padding: 2rem; border: 1px solid #374151; border-radius: 1rem; background: #1f2937; }
    .scroll-frame:focus-visible { outline: 3px solid #60a5fa; outline-offset: 4px; }
    .scroll-frame p { line-height: 1.7; }
  </style>
</head>
<body>
  <!-- Give keyboard users a named region they can focus and scroll -->
  <section class="scroll-frame" tabindex="0" role="region" aria-labelledby="scroll-title">
    <h1 id="scroll-title">A Field Guide to Deep Work</h1>
    <p>Deep work begins when distractions stop deciding where your attention goes. A clear boundary gives one demanding task enough room to develop.</p>
    <p>Short periods of concentration can produce more than long periods of fragmented effort. The difference comes from protecting the task from interruption.</p>
    <p>A useful session starts with a specific outcome. That outcome gives your attention a destination when the work becomes difficult.</p>
    <p>Your environment can make focus easier. Removing alerts and closing unrelated tabs reduces the number of choices competing for attention.</p>
    <p>Breaks also support concentration. A deliberate pause gives your mind time to recover before the next focused session begins.</p>
    <p>Consistency turns these choices into a practice. Each protected session makes it easier to return to demanding work tomorrow.</p>
  </section>
</body>
</html>

What does this code do?

  • The fixed height creates a visible window into the longer content.
  • The overflow-y: auto declaration gives the browser control of vertical scrolling.
  • The tabindex="0" attribute places the container in the normal keyboard focus order.
  • The role="region" attribute identifies the focused area as a named region.
  • The aria-labelledby attribute uses the visible heading as that region's accessible name.
  • Save index.html.
  • Open index.html from your Desktop by double-clicking it in your computer's file browser.

You should see a dark card containing a heading and several paragraphs. A native scrollbar should show that more content continues below the frame.

Page not opening correctly?

Confirm the filename ends with index.html. Check that your editor did not add another file extension.

If you see code as plain text, open the saved file in your browser instead of previewing it inside the editor.

Help me fix my local HTML page.

Hide the native scrollbar safely

The custom bar added later needs modern layout features. A feature query keeps the native scrollbar visible when those features are unavailable.

  • Place the following block immediately above </style> in index.html:
    @supports (anchor-name: --scroll-frame) and (animation-timeline: scroll()) {
      /* Hide the native bar only when the custom CSS path is available */
      .scroll-frame { scrollbar-width: none; }
      .scroll-frame::-webkit-scrollbar { display: none; }
    }

Why use a feature query?

  • The @supports rule applies the enclosed styles only when both enhanced features are available.
  • The scrollbar-width: none declaration hides a supported native scrollbar without disabling scrolling.
  • The ::-webkit-scrollbar rule covers browsers that expose the prefixed scrollbar selector.
  • The original overflow-y: auto behavior remains in control of the content.
  • Save index.html.
  • Refresh the page in the browser from earlier.

You should see the same overflowing card without its native scrollbar. The text should remain clipped at the bottom edge of the frame.

Native scrollbar still visible?

  • Confirm the new block sits immediately before the closing </style> tag.
  • Check that every opening brace has a matching closing brace.
  • Use a current Chrome or Safari browser if the feature query does not match.

Help me check why the scrollbar-hiding feature query is not applying.

✔️ Awesome, I've got everything!

Great. Double check that index.html is saved before testing both scrolling methods.

ⓧ I'd like to double check the full code

Compare your complete index.html file with this version:

<!doctype html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Custom Scrollbar</title>
  <style>
    /* Frame the demo and keep the page itself still */
    * { box-sizing: border-box; }
    body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: #111827; color: #e5e7eb; font-family: system-ui, sans-serif; }
    /* Turn the fixed-height region into the page's scroll container */
    .scroll-frame { width: min(32rem, calc(100vw - 2rem)); height: 24rem; overflow-y: auto; padding: 2rem; border: 1px solid #374151; border-radius: 1rem; background: #1f2937; }
    .scroll-frame:focus-visible { outline: 3px solid #60a5fa; outline-offset: 4px; }
    .scroll-frame p { line-height: 1.7; }
    @supports (anchor-name: --scroll-frame) and (animation-timeline: scroll()) {
      /* Hide the native bar only when the custom CSS path is available */
      .scroll-frame { scrollbar-width: none; }
      .scroll-frame::-webkit-scrollbar { display: none; }
    }
  </style>
</head>
<body>
  <!-- Give keyboard users a named region they can focus and scroll -->
  <section class="scroll-frame" tabindex="0" role="region" aria-labelledby="scroll-title">
    <h1 id="scroll-title">A Field Guide to Deep Work</h1>
    <p>Deep work begins when distractions stop deciding where your attention goes. A clear boundary gives one demanding task enough room to develop.</p>
    <p>Short periods of concentration can produce more than long periods of fragmented effort. The difference comes from protecting the task from interruption.</p>
    <p>A useful session starts with a specific outcome. That outcome gives your attention a destination when the work becomes difficult.</p>
    <p>Your environment can make focus easier. Removing alerts and closing unrelated tabs reduces the number of choices competing for attention.</p>
    <p>Breaks also support concentration. A deliberate pause gives your mind time to recover before the next focused session begins.</p>
    <p>Consistency turns these choices into a practice. Each protected session makes it easier to return to demanding work tomorrow.</p>
  </section>
</body>
</html>
Test wheel and keyboard scrolling

The hidden bar changes the container's appearance. The browser still processes the scrolling input underneath.

Before you test it, consider this question: will wheel input and keyboard input move the same content.

  • Place your pointer over the dark scroll container.
  • Scroll downward with your mouse wheel or trackpad.

You should see later paragraphs move into the frame. The surrounding page should remain still.

  • Reload the page to return the container to the top.
  • Press Tab until the container has a blue focus outline.
  • Press Arrow Down several times.

You should see the same content move upward inside the focused container. The native scrollbar remains hidden in a supported browser.

Content not moving?

  • Check that your pointer is over the dark container before using the wheel.
  • Confirm the blue focus outline is visible before pressing Arrow Down.
  • Verify that overflow-y: auto still appears in the .scroll-frame rule.

Help me debug scrolling in my focusable container.

That's the key behavior locked in: your custom-looking container still responds to native wheel and keyboard controls.

Your scrollable foundation is working. Next, you will pin a custom track to the container's frame.

Anchor the Scrollbar Track

Your content container already responds to the mouse wheel. Keyboard scrolling works too.

The custom track now needs to follow the container frame when the browser window changes size. CSS anchor positioning creates that link without changing the browser's scrolling behavior.

In this step, get ready to:
  • Give the existing scroll container a CSS anchor.
  • Add a visible custom track beside the container.
  • Confirm the track stays pinned during browser resizing.
Add the anchor and track element

An anchor name gives the scroll container a stable CSS reference. The track uses that reference to follow the container frame.

  • In index.html, find the CSS rule that contains the scroll container's overflow declaration.
  • Place the following declaration immediately before that rule's closing brace:
/* Name this frame so the track can follow it */
anchor-name: --scrollbar;

What Does the Anchor Name Do?

  • The anchor-name property makes the scroll container available as a positioning reference.
  • The dashed value --scrollbar gives that reference a name the track can use.
  • In index.html, find the closing tag for the scrolling content container.
  • Add the track immediately after that closing tag by pasting:
<!-- Place the custom track outside the scrolling content -->
<div class="bars"></div>

Why Is the Track Outside the Container?

Content inside the scroll container moves when you scroll. Placing .bars after the container keeps the track outside that moving content.

  • Save index.html.
  • Refresh the page in the browser.
  • Use the browser's element inspector to confirm that one div.bars element sits immediately after the scrolling container.

The page now contains the track element. Its position becomes visible after the next CSS rule.

Track Element Missing?

Check that the div.bars element is outside the scrolling container's closing tag. Confirm that index.html is saved before refreshing the browser.

Ask for help with the track placement.

Position and style the track

The track can now use position-anchor to select the named container. Edge values from anchor() keep its height aligned with the frame.

  • Add the track rule to the CSS section of index.html by pasting:
.bars {
  /* Pin the track beside the scroll container's frame */
  pointer-events: none;
  position: absolute;
  position-anchor: --scrollbar;
  top: anchor(top);
  left: anchor(right);
  bottom: anchor(bottom);
  width: 0.75rem;
  margin-left: 0.5rem;
  border-radius: 999px;
  background: #cbd5e1;
}

What Does This CSS Do?

  • The position-anchor property connects the track to --scrollbar.
  • The top value copies the container's top edge.
  • The bottom value copies the container's bottom edge.
  • The left value places the track at the container's right edge.
  • The margin-left value adds a small gap beside the frame.
  • The pointer-events value prevents the empty track from intercepting pointer input.
  • Save index.html.
  • Refresh the page in the browser.

You should see a narrow rounded track beside the right edge of the scrolling container.

Track in the Wrong Place?

Confirm that --scrollbar is spelled identically in anchor-name and position-anchor. Check that the .bars rule includes all three anchor() values.

Ask for help with the anchored track.

✔️ Awesome, I've got everything!

Great. The track is now anchored to the scrolling frame.

ⓧ I'd like to double check the full code

  • Compare the anchor declaration in your existing scroll container rule with this reference:
/* Name this frame so the track can follow it */
anchor-name: --scrollbar;
  • Compare the track element after your scrolling container with this reference:
<!-- Place the custom track outside the scrolling content -->
<div class="bars"></div>
  • Compare the track positioning rule with this reference:
.bars {
  /* Pin the track beside the scroll container's frame */
  pointer-events: none;
  position: absolute;
  position-anchor: --scrollbar;
  top: anchor(top);
  left: anchor(right);
  bottom: anchor(bottom);
  width: 0.75rem;
  margin-left: 0.5rem;
  border-radius: 999px;
  background: #cbd5e1;
}
Test the anchored position

Before you resize, predict whether the track will stay beside the container or drift away from it.

  • Resize the browser window until the content container becomes noticeably narrower.

The track should remain beside the container's right edge. Its height should continue to match the container frame.

  • Place the pointer over the scrolling content.
  • Use the mouse wheel to scroll through the content.

The content should move while the custom track stays fixed against the frame.

  • Select the scrolling container with the mouse.
  • Press the Arrow Down key several times.

The content should continue moving through keyboard input. That confirms the custom track has not replaced the browser's scrolling behavior.

You have locked the custom track to the container frame. Resizing no longer pulls the two pieces apart.

The track now has a stable home. Next up, you will connect scroll progress to a moving thumb.

Animate the Scroll Thumb

Your custom track now stays pinned to the container frame while the content moves. A stationary thumb still gives no clue how far you have scrolled.

A scroll timeline lets CSS convert the container's scroll position into animation progress. You will use that progress to move the thumb from the top of the track to the bottom.

In this step, get ready to:
  • Register a typed custom property for the thumb's position.
  • Connect the thumb animation to the container's scroll progress.
  • Preserve native wheel scrolling and keyboard scrolling.
Animate the Thumb With Scroll Progress

A regular custom property behaves like an untyped string. Registering --thumb-offset with @property gives the browser a value it can interpolate smoothly.

  • Return to index.html in your text editor.
  • Find the closing </style> tag.
  • Add the thumb animation immediately above that tag by pasting this code:
/* Give the browser a typed value it can interpolate */
@property --thumb-offset {
  syntax: "<length-percentage>";
  inherits: false;
  initial-value: 0%;
}

/* Move the thumb across the available track length */
@keyframes move-thumb {
  from { --thumb-offset: 0%; }
  to { --thumb-offset: calc(100% - 3rem); }
}

/* Share the named timeline across the component */
body { timeline-scope: --scrollbar-progress; }
.scroll-container { scroll-timeline: --scrollbar-progress block; }

/* Draw the thumb and bind it to scroll progress */
.scrollbar-track::before {
  content: "";
  position: absolute;
  inset-block-start: var(--thumb-offset);
  block-size: 3rem;
  inline-size: 100%;
  background: currentColor;
  border-radius: 999px;
  animation: move-thumb linear both;
  animation-timeline: --scrollbar-progress;
}

What Does This CSS Do?

  • The @property rule registers --thumb-offset as a length or percentage.
  • The move-thumb keyframes map the start of the scroll range to the top of the track.
  • The scroll-timeline declaration names the container's vertical scroll progress.
  • The timeline-scope declaration makes that named timeline available to the pinned track.
  • The thumb pseudo-element uses animation-timeline to follow scrolling instead of elapsed time.

Before you test, predict this: will the thumb stay still or follow the content? Keep your answer in mind.

  • Save index.html.
  • Return to the browser page from earlier.
  • Refresh the page.
  • Move your pointer over the scroll container.
  • Scroll toward the middle with your wheel or trackpad.

You should see the thumb travel down the track as the content moves. The thumb should reverse when you scroll upward.

Thumb Staying at the Top?

  • Confirm the HTML still uses scroll-container for the scrolling element.
  • Confirm the pinned track still uses scrollbar-track.
  • Keep animation-timeline after the animation declaration because the shorthand resets the timeline.

Ask for help with the exact CSS in your file: help me debug why my scroll timeline does not move the scrollbar thumb

✔️ Awesome, I've got everything!

Your animation styles are saved. The thumb now follows the container's scroll progress.

ⓧ I'd like to double check the full code

Compare the animation section in index.html with this reference:

/* Give the browser a typed value it can interpolate */
@property --thumb-offset {
  syntax: "<length-percentage>";
  inherits: false;
  initial-value: 0%;
}

/* Move the thumb across the available track length */
@keyframes move-thumb {
  from { --thumb-offset: 0%; }
  to { --thumb-offset: calc(100% - 3rem); }
}

/* Share the named timeline across the component */
body { timeline-scope: --scrollbar-progress; }
.scroll-container { scroll-timeline: --scrollbar-progress block; }

/* Draw the thumb and bind it to scroll progress */
.scrollbar-track::before {
  content: "";
  position: absolute;
  inset-block-start: var(--thumb-offset);
  block-size: 3rem;
  inline-size: 100%;
  background: currentColor;
  border-radius: 999px;
  animation: move-thumb linear both;
  animation-timeline: --scrollbar-progress;
}
Confirm Native Scrolling Still Works

The custom thumb reports scroll progress while the browser remains in control of scrolling. This keeps the container responsive to wheel input and keyboard input.

Before you check, predict where the thumb should finish after the container reaches its final line. Hold that position in mind.

  • Click inside the scroll container to focus it.
  • Press End on your keyboard.

You should see the final content line. The thumb should sit at the bottom edge of the track.

  • Press Home on your keyboard.

You should return to the first content line. The thumb should return to the top of the track.

Keyboard Scrolling Not Responding?

Click inside the scroll container before pressing a navigation key. Confirm the focusability attribute from the first step is still present.

If wheel scrolling works but keyboard scrolling does not, help me restore keyboard scrolling without removing the custom thumb

Your thumb now reports the container's scroll position while native scrolling stays intact. Next, you will make the thumb's size reflect how much content is visible.

Size the Thumb for Content

That is the motion solved. Your scroll timeline now carries the thumb from one end of the track to the other.

A fixed-height thumb gives every page the same visual weight. In this step, CSS uses container query units to show how much of the content is visible.

In this step, get ready to:
  • Turn the custom track into a size query container.
  • Calculate the thumb size from the container block size.
  • Compare the thumb on a short page against the thumb on a long page.
Turn the track into a size container

The thumb needs a measurement that follows the track itself. A size query container gives its descendants access to that measurement.

  • Open the existing index.html file in the editor you used earlier.
  • Find the existing .bars rule.
  • Find the existing .bar rule.
  • Add the new declarations to those rules by matching these focused updates:
.bars {
  container-type: size;
}

.bar {
  animation-range: 0% calc(100% + 100cqb), calc(0% - 100cqb) 100%;
  animation-fill-mode: both;
}

How Does This Size the Thumb?

  • The .bars element becomes the size query container for its thumb.
  • The value 100cqb represents the full block size of that container.
  • The two animation-range values adjust the thumb edges against the container size.
  • The animation-fill-mode value keeps both edge positions applied at the limits of the timeline.
  • Save index.html.

Before you refresh, what shape do you expect the fixed thumb to become? Hold that prediction.

  • Return to the browser from earlier.
  • Refresh the page.

You should see a thumb whose height represents the visible portion of the long content. It should still sit at the top of the track before you scroll.

Thumb Size Has Not Changed?

  • Confirm that container-type is inside the .bars rule.
  • Check that the two animation-range values are separated by a comma.
  • Compare both calc() expressions with the focused update above.

Ask for help with the exact CSS if the thumb remains fixed: help me debug the container query sizing in my custom scrollbar.

Compare short content with long content

Thumb size acts as a visual ratio between the visible frame and the complete content. Changing the content length lets you test that ratio directly.

  • Return to index.html in your editor.
  • Remove complete repeated content elements from the lower half of the scroll container.
  • Save index.html.

Before you refresh, which version should give the thumb more height? Hold that prediction.

  • Return to the browser.
  • Refresh the page.

You should see a taller thumb because the frame now reveals a larger proportion of the shorter content.

  • Return to your editor.
  • Press Cmd+Z on macOS or Ctrl+Z on Windows to restore the removed content.
  • Save index.html.

Before you refresh again, how should restoring the longer content affect the thumb? Hold that prediction.

  • Return to the browser.
  • Refresh the page.

You should see the thumb return to its shorter height. The content length now controls the visual size automatically.

Shorter Content Broke the Page?

Undo the edit if you removed a closing HTML tag. Remove only complete repeated content elements inside the scrolling container.

If the thumb does not return to its earlier size after restoring the content, help me check the restored HTML structure.

Confirm the thumb still travels

The size calculation changes the distance available to each edge of the thumb. The existing scroll timeline should still carry the thumb across that adjusted distance.

Before you test the long page, do you expect the resized thumb to stop early or reach the bottom edge? Hold that prediction.

  • Click inside the scrolling container.
  • Press the End key.

You should see the content reach its end. The bottom of the thumb should align with the bottom of the track.

  • Press the Home key.

You should see the content return to the start. The top of the thumb should align with the top of the track.

Your scrollbar now communicates position through movement. It also communicates content length through size. Next up, you will make the thumb draggable.

Drag the Thumb to Scroll

Your CSS thumb already mirrors the container's scroll progress. The remaining gap appears when someone reaches for the custom thumb itself.

A small piece of JavaScript uses Pointer Events to turn that drag into scroll progress. The browser still controls the real scroll position through scrollTo().

In this step, get ready to:
  • Capture the starting position when the thumb receives a pointer press.
  • Convert pointer movement into a new scroll position.
  • Confirm that dragging works alongside wheel input and keyboard input.
Add drag behavior to the thumb

The drag starts with a snapshot of the pointer position. Each movement compares against that snapshot to calculate matching scroll progress.

  • Return to index.html in the text editor from earlier.
  • Scroll to the bottom of index.html.
  • Add this script after the existing custom scrollbar markup:
<script>
const scrollbar = document.querySelector('.scrollbar');
const bar = document.querySelector('.bar');

bar.addEventListener('pointerdown', ({ pageY }) => {
  // Capture the starting scroll state for this drag.
  const { scrollTop, offsetHeight, scrollHeight } = scrollbar;

  // Convert pointer travel into the matching scroll distance.
  const move = (event) => {
    const top = scrollTop + (event.pageY - pageY) / offsetHeight * scrollHeight;
    scrollbar.scrollTo({ top });
  };

  // Release the document listeners when the drag ends.
  const end = () => {
    document.removeEventListener('pointermove', move);
    document.removeEventListener('pointerup', end);
  };

  document.addEventListener('pointermove', move);
  document.addEventListener('pointerup', end);
});
</script>

What does this code do?

  • The pointerdown handler captures the starting pointer position. It also captures the container's current scroll measurements.
  • The pointermove handler converts vertical pointer distance into scroll distance. The ratio compares the visible height with the total content height.
  • The scrollTo() call updates the container's native scroll position. Your existing scroll timeline keeps the thumb synchronized.
  • The pointerup handler removes the temporary document listeners. Releasing the pointer therefore ends the drag.

Before you try the drag, consider whether the content will move pixel for pixel or by matching scroll progress.

  • Save index.html.
  • Switch back to the browser page from earlier.
  • Refresh the page.
  • Drag the custom thumb from near the top of its track toward the bottom.

You'll see the content follow your pointer through the document. The thumb remains synchronized because dragging changes the container's native scroll position.

Thumb not responding to a drag?

  • Confirm that the scrolling element still uses the .scrollbar class.
  • Confirm that the draggable thumb still uses the .bar class.
  • Check that the script appears after the custom scrollbar markup in index.html.

Help me debug the thumb drag behavior.

✔️ Awesome, dragging works

Great. Your custom thumb now controls the same native scroll position used by the wheel and keyboard.

ⓧ I'd like to double check the full code

Compare the complete drag block at the bottom of index.html with this reference:

<script>
const scrollbar = document.querySelector('.scrollbar');
const bar = document.querySelector('.bar');

bar.addEventListener('pointerdown', ({ pageY }) => {
  // Capture the starting scroll state for this drag.
  const { scrollTop, offsetHeight, scrollHeight } = scrollbar;

  // Convert pointer travel into the matching scroll distance.
  const move = (event) => {
    const top = scrollTop + (event.pageY - pageY) / offsetHeight * scrollHeight;
    scrollbar.scrollTo({ top });
  };

  // Release the document listeners when the drag ends.
  const end = () => {
    document.removeEventListener('pointermove', move);
    document.removeEventListener('pointerup', end);
  };

  document.addEventListener('pointermove', move);
  document.addEventListener('pointerup', end);
});
</script>
Check every scrolling input still works

Dragging adds another way to update the container's existing scroll position. Wheel input and keyboard input should keep working through the browser's native behavior.

Before the final check, consider whether every input method will update the same native scroll position.

  • Drag the thumb to the middle of its track.

You'll see the content settle around the middle of the page. The thumb follows your cursor throughout the drag.

  • Scroll inside the container with your mouse wheel or touchpad.

You'll see the content continue moving. The thumb tracks the new position without losing its calculated size.

  • Click the scrolling content to give it keyboard focus.
  • Press the Page Down key.

You'll see the content advance through the page. The thumb moves with it because every input method updates the same scroll container.

Keyboard scrolling not working?

Click inside the scrolling content before pressing the key. Confirm that the container still has the keyboard-focusable attribute added earlier.

Help me restore keyboard scrolling.

That closes the final interaction gap. Next up, you'll test the fallback that restores the native scrollbar when the CSS enhancement is unavailable.

Verify the Fallback Scrollbar

Your custom scrollbar works in the browser you have been using. A browser that ignores the scroll timeline could still inherit the rule that hides its native scrollbar.

In this step, you will use a CSS feature query to limit the custom layer. You will verify the enhanced path before testing the native fallback.

In this step, get ready to:
  • Scope the custom scrollbar styles behind a feature query.
  • Confirm the custom thumb still follows scroll progress.
  • Prove the native scrollbar remains available without scroll timeline support.
Protect the native scrollbar

The container's overflow behavior remains the dependable base. The custom layer only activates when the browser supports the timeline that drives it.

  • Switch back to index.html from earlier.
  • Find the CSS declarations that hide the native scrollbar.
  • Wrap the native-scrollbar hiding declarations plus every custom component style in an @supports feature query that tests the scroll timeline declaration already used in the file.
  • Save index.html.

How Does the Fallback Work?

The @supports feature query activates when the browser recognizes the tested declaration. Native scrollbar hiding stays inside that condition.

An unsupported browser ignores the custom layer. The container keeps its overflow behavior, so the browser can provide its own scrollbar.

Before you reload, picture which scrollbar you expect to see in the supported browser.

  • Reload the page in the supported browser from earlier.
  • Scroll the container with your mouse wheel or trackpad.
  • Focus the scrolling container.
  • Scroll the focused container with your keyboard.
  • Drag the custom thumb from top to bottom.

You should still see the custom track beside the container. The thumb should follow each scrolling method.

The native scrollbar should remain hidden because this browser activates the enhanced layer.

Seeing the Native Bar in the Supported Browser?

  • Check that the feature query tests the same scroll timeline declaration already used by the thumb.
  • Confirm that the feature query's closing brace comes after every custom component style.
  • Check that index.html is saved before reloading the page.

Ask for help with the feature query if the native bar stays visible. Help me debug my scrollbar feature query

Test the native fallback

The fallback test uses a browser where the feature query evaluates false. This isolates the path that preserves native scrolling controls.

A standard Firefox release provides the unsupported path described for this project. Another installed browser without scroll timeline support works for the same check.

  • Press Cmd+Space on macOS or the Windows key on Windows to open app search.
  • Type Firefox into the search field.
  • Press Enter to open Firefox.

Before you load the page, picture whether scrolling survives when the custom layer stays inactive.

  • Drag index.html from your editor's file sidebar into the Firefox window.
  • Drag the browser's native scrollbar thumb down the container.
  • Scroll the container with your mouse wheel or trackpad.
  • Focus the scrolling container.
  • Scroll the focused container with an arrow key.

You should see the browser's native scrollbar beside the container. Dragging its thumb should move the content.

Wheel scrolling should still work. Keyboard scrolling should move the same content.

You have closed the compatibility gap. Every visitor now keeps a working scrollbar when the custom enhancement is unavailable.

Native Scrollbar Still Missing?

  • Confirm that every native-scrollbar hiding declaration sits inside the feature query.
  • Confirm that the custom component styling also sits inside the feature query.
  • Reload index.html after saving the latest CSS changes.

Get help tracing the fallback if the container still lacks native controls. Help me restore the fallback scrollbar

Secret mission

Adapt the Scrollbar for Horizontal Content

Turn the existing vertical component into a horizontal scrollbar for wide content. Keep browser-owned scrolling, custom thumb dragging, responsive sizing, and the native fallback working after the axis change.

Clean Up Your Resources

Clean Up Your Resources

Everything in this project runs locally, so there are no ongoing costs. Decide whether to keep your page available, close it for now, or delete it entirely.

Resources you used:

  • One local index.html file containing your custom scrollbar component.

Keep everything running

No action is needed. Choose this if you are still testing or improving your custom scrollbar.

  • Keep index.html in its current location.
  • Leave the page open in your browser if you want to continue testing scrolling or dragging.

Pause - I'll come back to this later

Close the page to pause your testing. Your local file stays ready for the next time you want to work on it.

  • Close the browser tab displaying your custom scrollbar.
  • Keep index.html in the location where you saved it.
  • Open index.html in a modern browser when you want to continue.

Delete - I don't want to use this again

Remove the local page when you no longer want to keep the component. Deleting index.html affects only this project file.

macOS

  • Close the browser tab displaying your custom scrollbar.
  • Press Cmd+Space to open macOS search.
  • Type Finder into the search field.
  • Press Enter to open Finder.
  • Locate index.html in the folder where you saved it.
  • Move index.html to Trash.
  • Empty Trash to remove the file permanently.
  • Return to the original folder to confirm that index.html is no longer listed.

Windows

  • Close the browser tab displaying your custom scrollbar.
  • Press the Windows key to open Windows search.
  • Type File Explorer into the search field.
  • Press Enter to open File Explorer.
  • Locate index.html in the folder where you saved it.
  • Move index.html to the Recycle Bin.
  • Empty the Recycle Bin to remove the file permanently.
  • Return to the original folder to confirm that index.html is no longer listed.

Nice Work!

Nice Work!

You did it! You built a custom scrollbar with progressive enhancement while keeping the browser in charge of real scrolling. Wheel, keyboard, touch, pointer dragging, and native fallback controls keep the content usable across browser support levels.

You've learned how to:

  • Build a standalone index.html page with an overflowing, keyboard-focusable scroll container that preserves wheel, keyboard, and touch scrolling.
  • Use CSS anchor positioning to pin a custom track to its frame. Drive a container-query-sized thumb with a scroll timeline so its position and size respond to scrolling and content length.
  • Add JavaScript pointer handlers that turn thumb dragging into scrollTo calls. Keep the native scrollbar fallback available when scroll timeline support is missing.
  • Secret Mission: Adapt the same progressive enhancement pattern to a horizontal scroller that preserves native input and fallback behavior.

Ready to quiz yourself?