A Few Words About Myself
Hi, my name is Artur and I love smooth interfaces, CSS, and modern approaches. Today I want to talk to you about a task that probably faces every frontend developer: creating a gallery.
Problem Statement
Let's put it this way - maybe it's not even a gallery at all. The name isn't so important, what matters is the task. We can describe it like this: we need a mobile-first swipeable slider. The swipes should feel as native as possible and work without dropping frames. Even though it's mobile-first, we can't ignore desktops with their mouse interactions.
How It Was Done for Ages
For many years, even decades, when a designer would mention the word "swipe", developers would immediately feel a bit gloomy, knowing they'd have to deal with all sorts of JavaScript calculations, work with touch events, and recall how the position property works in CSS.
If the designer was being particularly demanding, you'd even have to study some laws of physics, trying to figure out how to recreate the spring effect in JavaScript: that's exactly how a swipe looks when it reaches its boundary on almost all modern smartphones.
Anyway, armed with the laws of dynamics, position/left/right/transform properties, and the pointer events family, developers get to work. Eventually, one of them will give up and Google "the most popular swipeable gallery on JS", while another will see it through to the end only to notice that despite all their efforts, they couldn't achieve that native feel.
The complexity of such approaches lies in the fact that besides learning all of the above, you also need to be an expert in how browser rendering works, because attaching all your carefully described calculations to pointermove is typically fatal from a rendering performance perspective.
I mean, why are we torturing ourselves? Are we in 2025 or what?
Modern CSS Comes to the Rescue
I don't know about you, but when I see a gallery, I imagine a block with scroll functionality. Scroll is wonderful because it does a lot of work for us:
- Provides element scrolling without JS
- Provides edge overscroll without JS
- Allows tracking progress through DOM events if needed
Let's try to implement our gallery using horizontal scroll.
Gallery as a Block with Horizontal Scroll
import { Children, ReactNode } from "react";
export const Gallery = ({ children }: { children?: ReactNode; }) => {
return (
<div>
<div style={{ overflowX: "auto", display: "flex" }}>
{Children.map(children, (child, i) => (
<div style={{ width: "100%", flexShrink: 0 }} key={i}>
{child}
</div>
))}
</div>
</div>
);
};
Looks amazing, in my opinion, but obviously we'll encounter a few problems here:
- Scrollbar visibility.
- Continuous scrolling: we need the swipe to smoothly stop at the boundary between slides.
Hiding the Scrollbar
Previously, to hide the scrollbar, we had to hack around with additional HTML elements, the overflow
property, and hardcode scrollbar dimensions.
Then, we got the wonderful ::-webkit-scrollbar-*
.
Since December 2024, we don't need to deal with any of that anymore: the scrollbar-width
property has become available in all popular browsers:
import { Children, ReactNode } from "react";
export const Gallery = ({ children }: { children?: ReactNode; }) => {
return (
<div>
<div
style={{
overflowX: "auto",
display: "flex",
scrollbarWidth: "none"
}}
>
{Children.map(children, (child, i) => (
<div style={{ width: "100%", flexShrink: 0 }} key={i}>
{child}
</div>
))}
</div>
</div>
);
};
Eliminating Continuous Scrolling
The most interesting part. 2024 brought us not only widespread support for invisible scrollbars but also support for the scroll-snap
property family. Snap, as the name suggests, means a "click". This click is exactly what we need.
A Few Words About This Family
Generally speaking, the properties in this family allow flexible configuration of scroll behavior especially when a block contains child elements. Each element can become an intermediate scroll stop point. The scroll-snap-type
property is responsible for this.
Besides stop points, you can control things like distance (scroll-snap-padding
, scroll-snap-margin
) between intermediate points, alignment of these points (scroll-snap-align
), and whether points can be skipped if the gesture's kinetics suggest it (scroll-snap-stop
).
We won't use all the properties in this family, but if you want to learn more about any of them, I recommend this MDN article. It has interactive examples, detailed descriptions, and information about browser support.
Our task now is to add intermediate scroll stop points. For this, we'll use the scroll-snap-type
and scroll-snap-stop
properties.
Adding Intermediate Scroll Stop Points
<div
style={{
overflowX: "auto",
display: "flex",
scrollbarWidth: "none",
scrollSnapType: "x mandatory",
scrollSnapStop: "always"
}}
>
{Children.map(children, (child, i) => (
<div style={{ width: "100%", flexShrink: 0 }} key={i}>
{child}
</div>
))}
</div>
As you can see, the missing gallery scrolling mechanic is implemented by adding literally two CSS properties.
The value of the scroll-snap-type: x mandatory
property means that scrolling along the X-axis should stop at a snap point. In addition to mandatory
, there is also proximity
, which delegates the decision about stopping to the specific browser. However, we don’t need that here. The snap point in this case is the left edge of each child element (slide).
The value of the scroll-snap-stop: always
property tells the browser not to skip a slide, even if the swipe was very strong.
Custom Slide Width, Alignment, and Spacing Between Slides
Slides don’t always need to match the width of their parent, so let’s add a property to adjust their width. And since we’re allowing width customization, alignment relative to the parent will also come in handy. For alignment, we’ll use the previously mentioned CSS property scroll-snap-align
.
As for adjusting the spacing between slides, there’s no need to reinvent the wheel — we’ll stick to the good old gap
:
import { Children, ReactNode } from "react";
export const Gallery = ({
children,
slideWidth = "100%",
slideAlign = "center",
slidesGap = 0
}: {
children?: ReactNode;
slideWidth?: string | number;
slideAlign?: "start" | "center" | "end";
slidesGap?: string | number;
}) => {
return (
<div>
<div
style={{
overflowX: "auto",
display: "flex",
scrollbarWidth: "none",
scrollSnapType: "x mandatory",
scrollSnapStop: "always",
gap: slidesGap
}}
>
{Children.map(children, (child, i) => (
<div
style={{
width: slideWidth,
scrollSnapAlign: slideAlign,
flexShrink: 0,
}}
key={i}
>
{child}
</div>
))}
</div>
</div>
);
};
Please note that the newly introduced
scroll-snap-align
property usesstart
andend
instead oftop
,left
,bottom
, andright
. These are so-called logical values: they are more concise and eliminate the need to worry about flipped interfaces (e.g., interfaces in languages like Arabic).
If you’re curious about these logical values, you can read this article on CSS Tricks or watch my webinar.
Allowing Customization of the Initial Slide
Alright, let’s do some coding. Developers will definitely want to render a gallery with an initial slide that’s different from the first one. For this, we can’t avoid using TypeScript:
Here’s an example implementation:
import { Children, ReactNode, useEffect, useRef } from "react";
export const Gallery = ({
children,
slideWidth = "100%",
slideAlign = "center",
slidesGap = 0,
initialSlideIndex = 0,
}: {
children?: ReactNode;
slideWidth?: string | number;
slideAlign?: "start" | "center" | "end";
slidesGap?: string | number;
initialSlideIndex?: number;
}) => {
const scrollContainer = useRef<HTMLDivElement>(null);
useEffect(() => {
if (initialSlideIndex > 0) {
scrollContainer.current?.children[initialSlideIndex].scrollIntoView({
behavior: "instant",
block: "nearest",
});
}
}, []);
return (
<div>
<div
style={{
overflowX: "auto",
display: "flex",
scrollbarWidth: "none",
scrollSnapType: "x mandatory",
scrollSnapStop: "always",
gap: slidesGap
}}
ref={scrollContainer}
>
{Children.map(children, (child, i) => (
<div
style={{
width: slideWidth,
scrollSnapAlign: slideAlign,
flexShrink: 0,
}}
key={i}
>
{child}
</div>
))}
</div>
</div>
);
};
Let’s Break It Down
useRef
and useEffect
are React concepts, so we won’t dwell on them too long. Using useRef
, we can get a reference to a DOM element, and with useEffect
, we execute a snippet in a callback after the component’s first render.
What’s particularly interesting here is the scrollIntoView
method of DOM elements. This method allows a parent element to scroll itself so that the specified child element becomes visible.
Important: The method should be called on a child element, not on the parent.
The method’s options are just as noteworthy:
-
behavior
: Controls the type of scrolling. It can be either instant or smooth. For our use case, we need it to be instant. -
block
: Determines which parent elements the scroll is applied to. If the valuenearest
is not specified, you might notice that not only the gallery scrolls, but also the document itself. That’s something we don’t want.
For all the details and nuances of this method, you can refer to this MDN article.
Desktop Support
At the beginning of the article, I mentioned that while we are mobile-first, desktops can’t be overlooked. On desktops, there’s often no convenient way to scroll horizontally, and even when there is, it’s not considered a familiar UX pattern. On desktops, mouse clicks reign supreme. That’s why I suggest adding arrows on the sides of the gallery.
Let’s make the visibility of these arrows optional. Additionally, I propose leaving the appearance of the arrows to the discretion of the component’s users. We’ll just define some basic properties.
Here’s how we can implement this:
import { Children, ReactNode, useEffect, useRef, CSSProperties } from "react";
type Arrow = {
content: ReactNode;
inlineOffset: number | string;
};
const arrowStyles: CSSProperties = {
position: "absolute",
top: "50%",
transform: "translateY(-50%)",
zIndex: 2,
};
export const Gallery = ({
children,
slideWidth = "100%",
slideAlign = "center",
slidesGap = 0,
initialSlideIndex = 0,
arrows,
}: {
children?: ReactNode;
slideWidth?: string | number;
slideAlign?: "start" | "center" | "end";
slidesGap?: string | number;
initialSlideIndex?: number;
arrows?: [Arrow, Arrow];
}) => {
const scrollContainer = useRef<HTMLDivElement>(null);
const activeSlideIndex = useRef<number>(initialSlideIndex);
useEffect(() => {
if (initialSlideIndex > 0) {
scrollContainer.current?.children[initialSlideIndex].scrollIntoView({
behavior: "instant",
block: "nearest",
});
}
}, []);
const scrollTo = (slideIndex: number) => {
if (activeSlideIndex.current !== slideIndex) {
scrollContainer.current?.children[slideIndex].scrollIntoView({
behavior: "smooth",
block: "nearest",
});
activeSlideIndex.current = slideIndex;
}
};
return (
<div>
<div style={{ position: "relative" }}>
{arrows && (
<div
style={{
...arrowStyles,
insetInlineStart: arrows[0].inlineOffset,
}}
onClick={() => {
if (activeSlideIndex.current > 0) {
scrollTo(activeSlideIndex.current - 1);
}
}}
>
{arrows[0].content}
</div>
)}
<div
style={{
overflowX: "auto",
display: "flex",
scrollbarWidth: "none",
scrollSnapType: "x mandatory",
scrollSnapStop: "always",
gap: slidesGap,
position: "relative",
zIndex: 1,
}}
ref={scrollContainer}
>
{Children.map(children, (child, i) => (
<div
style={{
width: slideWidth,
scrollSnapAlign: slideAlign,
flexShrink: 0,
}}
key={i}
>
{child}
</div>
))}
</div>
{arrows && (
<div
style={{
...arrowStyles,
insetInlineEnd: arrows[1].inlineOffset,
}}
onClick={() => {
if (activeSlideIndex.current + 1 < Children.count(children)) {
scrollTo(activeSlideIndex.current + 1);
}
}}
>
{arrows[1].content}
</div>
)}
</div>
</div>
);
};
It might feel like the code grew a lot, but it’s actually as simple as it gets:
- We allow passing a
arrows
property, which is an array of two elements. Each element is an object containing the arrow’s markup and its offset from the edge. - Vertical alignment and other shared styles are defined in the
arrowStyles
property. - We store the current slide index in a
ref
calledactiveSlideIndex
. I use aref
instead ofstate
because changes to this value shouldn’t trigger a re-render. - We write a
scrollTo
function, which scrolls the gallery to the index provided as an argument. Inside, you’ll see the reuse of the previously mentionedscrollIntoView
method, but this time with the parameter{ behavior: "smooth" }
to enable smooth scrolling when clicking an arrow. - And that’s pretty much it! 😊
The Future
In the realm of scrolling, the last few years have seen rapid development. Both CSS and JavaScript are evolving, and here are a couple of powerful innovations that, in my opinion, will soon become standard in all modern browsers.
Handy Events
It’s clear that, sooner or later, we’ll need to add event handlers like onChange
to our component. This can already be done using the current arsenal of browser events. At the very least, we have onscroll
. But anyone who’s worked with this event knows that you’ll need to use debouncing to avoid firing the handler dozens of times per second.
However, the HTML standard is likely to soon introduce the onscrollsnapchange
and onscrollend
events. These will let us solve such tasks in just a few lines of code. So, let’s wait for them!
Scroll Animation
This is just mind-blowing. The standard, already supported in browsers using the Chromium engine, allows you to describe property changes relative to scroll position directly in CSS. Once this standard is supported across all browsers, it will enable an infinite number of scroll-based animations without writing a single line of JavaScript.
I highly recommend checking out the examples and the documentation. It's all very interesting!
Conclusion
In conclusion, I’m incredibly excited about how rapidly and extensively web standards are evolving. I highly recommend keeping an eye on updates and, when faced with a new challenge, leveraging native technologies. These often allow you to create elegant, compact, and performant solutions of your own.
The solution discussed in the article managed to avoid any dependencies and ended up taking just over 100 lines of code.
Top comments (1)
🔥🔥🔥