DEV Community

Cover image for Writing the Perfect Mobile-First Gallery
Artur Stambultsian
Artur Stambultsian

Posted on

Writing the Perfect Mobile-First Gallery

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

Looks amazing, in my opinion, but obviously we'll encounter a few problems here:

  1. Scrollbar visibility.
  2. 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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

Please note that the newly introduced scroll-snap-align property uses start and end instead of top, left, bottom, and right. 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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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 value nearest 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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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 called activeSlideIndex. I use a ref instead of state 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 mentioned scrollIntoView 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)

Collapse
 
ykvlv profile image
Daria Y

🔥🔥🔥