Creating Custom Scrollbars for Your Website as a Web Component: A Workaround for iOS Safari Limitations with Jest Tests

Craig Roberts
6 min readFeb 12, 2023

--

Introduction

Custom scrollbars have been a popular design element for websites for years, allowing developers to add a unique look and feel to their sites, thus enhancing the overall user experience. Unfortunately, custom scrollbars are not supported natively on all browsers and operating systems, including Safari for iOS. Since the release of iOS 14 on September 16, 2020, custom scrollbars are no longer supported on iOS Safari, according to an official Apple Framework Engineer. This means that website developers cannot style the scrollbar using CSS and must rely on the default appearance determined by the operating system. However, with this guide, I will show you a workaround to create a custom scrollbar web component that works seamlessly on iOS Safari and test it using JEST.

It’s important to note that despite this limitation, there are still many other ways to create engaging and user-friendly websites by using other design elements and techniques such as customizing other parts of the site or using JavaScript-based scrollbar plugins.

Why a Web component?

Web components are reusable and modular units of code that can work on any framework or website. They allow developers to encapsulate specific functionalities into a standalone component, which can be easily imported and used across multiple projects. Using web components offers several benefits such as reusability, encapsulation, interoperability, portability, and consistency.

Why test with JEST?

Jest is a JavaScript testing framework designed to make it easier for developers to write and run tests for their code. It provides a complete and easy-to-use testing environment that covers a wide range of testing scenarios, including unit testing, integration testing, and end-to-end testing.

Understanding the problem

Custom CSS scrollbars are not natively supported on all browsers. To solve this problem, we need to create a solution that allows us to add functionality to any element by adding our own custom HTML tag element. This will then produce the scrollbar within the container, replacing the current scroller.

What HTML parts are required to build a custom scroller?

To build a custom scroller, we need to understand the core parts of a scroller. From looking at how current scrollers are created based on the pseudo-selectors on the MDN webdocs of the native scrollbar, I can see that the core parts of a scroller are the scrollbar and the scrollbar thumb.

Scrollbar: the entire scrollbar.

Scrollbar Thumb: the draggable scrolling handle.

Step 1: HTML Template

In this step, we create the foundation for our custom scrollbar web component by creating a container for the content and setting its height and width.

<!--LAYOUT CONTAINER, SETS MAX-WIDTH -->
<div class="container">
<custom-scrollbar data-target-id="scrollableElement"></custom-scrollbar>
<div id="scrollableElement"><!--CONTENT GOES HERE --></div>
</div>

Step 2: CSS Styles

In this step, we create the appearance of our custom scrollbar using CSS. We style the container, the scrollbar, and the thumb to match our desired look.

.container {
position: relative;
max-width: 400px;
}
#scrollableElement {
position: relative;
max-height: 200px;
border: 1px solid #DDDDDD;
padding: 1rem
}

Step 3: JavaScript Component

The JavaScript component of the custom scrollbar is responsible for its functionality. The component takes in various properties such as targetElId, trackColor, trackWidth, and thumbColor as data attributes, which can be set as the default or customised based on the user’s preference. The connectedCallback method is triggered when the custom element is added to the DOM, sets the target element, and shadow root, and adds styles to the document head. It also appends the custom scrollbar HTML to the shadow root. The moveScrollThumb method is called whenever the target element is scrolled and updates the position of the scrollbar thumb accordingly.

The custom scrollbar also has click-drag functionality implemented with mouse events.

class CustomScrollBar extends HTMLElement {
constructor() {
super();
}

connectedCallback() {

this.attachShadow({ mode: "open" });

this.targetElId = this.dataset.targetId;
this.trackColor = this.dataset.trackColor || "#f2f2f2";
this.trackWidth = this.dataset.trackWidth || "6px";
this.thumbColor = this.dataset.thumbColor || "#c1c1c1";
this.targetEl = document.querySelector(`#${this.targetElId}`);

const style = document.createElement("style");
style.textContent = `
.custom-scrollbar {
width: ${this.trackWidth};
height: 100%;
position: absolute;
top: 0;
right: 0;
}

.custom-scrollbar__track {
width: 100%;
height: 100%;
background-color: ${this.trackColor};
}

.custom-scrollbar__thumb {
width: 100%;
background-color: ${this.thumbColor};
border-radius: ${this.trackWidth};
position: absolute;
top: 0;
animation: top 0.25s ease-in;

}

.custom-scrollbar__thumb::hover {
background-color: red;
}
`;
this.shadowRoot.appendChild(style);

// add css to document head
const style2 = document.createElement("style");
style2.textContent = `
.prevent-scroll {
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
`;

document.head.appendChild(style2);

if (!this.targetEl) {
console.warn(
`CustomScrollBar: target element with id "${this.targetElId}" not found`,this, this.dataset.targetId
);
return;
}

const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
this.setTargetElCSS();
this.setScrollThumbHeight();
}
});
});

observer.observe(this.targetEl);

this.shadowRoot.innerHTML += `
<div class="custom-scrollbar">
<div class="custom-scrollbar__track">
<div class="custom-scrollbar__thumb"></div>
</div>
</div>
`;

this.targetEl.addEventListener("scroll", () => {
this.moveScrollThumb();
});
}

moveScrollThumb() {
const scrollBarThumb = this.shadowRoot.querySelector(".custom-scrollbar__thumb");
const scrollBarTrackHeight = this.shadowRoot.querySelector(".custom-scrollbar__track").offsetHeight;
const scrollThumbTop = (this.targetEl.scrollTop / this.targetEl.scrollHeight) * scrollBarTrackHeight;
scrollBarThumb.style.top = `${scrollThumbTop}px`;
}

setTargetElCSS() {
this.targetEl.style.overflowY = "scroll";
this.targetEl.style.position = "relative";
this.targetEl.style["-ms-overflow-style"] = "none"; /* Firefox */
this.targetEl.style.scrollbarWidth = "none"; /* Firefox */
}

setScrollThumbHeight() {
// get the scroll bar element
const scrollBarTrack = this.shadowRoot.querySelector(
".custom-scrollbar__track"
);
const scrollBarThumb = this.shadowRoot.querySelector(
".custom-scrollbar__thumb"
);

// get the scroll bar track height
const scrollBarTrackHeight = scrollBarTrack.offsetHeight;

// get the target element height
const targetElHeight = this.targetEl.offsetHeight;

// get the target element scroll height
const targetElScrollHeight = this.targetEl.scrollHeight;

// calculate the scroll thumb height
const scrollThumbHeight =
(targetElHeight / targetElScrollHeight) * scrollBarTrackHeight;

// set the scroll thumb height
scrollBarThumb.style.height = `${scrollThumbHeight}px`;

// attach click-drag event on the thumb
let isDragging = false;
let currentY;
scrollBarThumb.addEventListener("mousedown", (e) => {
isDragging = true;
currentY = e.clientY;
// prevent dragging hilights
this.preventContentHighlight("remove");
});
document.addEventListener("mouseup", () => {
isDragging = false;
// remove prevent dragging highlights
this.preventContentHighlight("add");
});
document.addEventListener("mousemove", (e) => {
if (!isDragging) return;
const deltaY = e.clientY - currentY;
currentY = e.clientY;
this.targetEl.scrollTop +=
deltaY * (targetElScrollHeight / scrollBarTrackHeight);
});
}

// prevent content highlight when dragging the thumb
preventContentHighlight(action) {
if (action) {
this.targetEl.classList.add("prevent-scroll");
} else {
this.targetEl.classList.add("prevent-scroll");
}
}
}

customElements.define("custom-scrollbar", CustomScrollBar);

Step 4: Testing with Jest

Finally, I will write and run tests using Jest to ensure that the custom scrollbar web component works as expected. I will test the various properties and functionalities of the component, including the appearance of the scrollbar, the ability to drag the thumb, and the ability to jump to a specific position within the content by clicking the track.

With these tests in place, I can be confident that the custom scrollbar web component will work seamlessly on iOS Safari and any other browser that supports web components.

Demo

You can see this in action and obtain the complete code by visiting my code pen below.

Conclusion

Well, folks, we’ve come to the end of this step-by-step guide, and I must say, it’s been a wild ride! From start to finish, we’ve covered everything you need to know to complete this task like a pro. Whether you’re a newbie or a pro developer, I’m confident that my instructions and demo will allow you to tackle this project with ease.

I hope that this step-by-step guide has helped you create your custom scrollbars and also taught you how to implement Jest into future projects so that you can ensure that your future components works as intended.
Now go ahead, and give this component a shot! I have no doubt that you’ll come out the other side with fantastic results that’ll leave everyone in awe.

Thanks for sticking around until the end! I hope you have enjoyed reading it! If you like this article, please subscribe and follow me to be notified of future posts.

--

--