How to create a horizontal, circular/scrollable menu?

Viewed 154

How can we make a horizontal row of link elements (with variable width/text length) with overflow hidden (or without, depending on how this is usually done..) function so that the last element is positioned behind the first and so on in each left or right direction, to create a circular scroll?

I have this so far:

const horizontalContainer = document.querySelector('.horizontal-container')
const horizontalLinks = document.querySelectorAll('.horizontal-link')

let touchStart = 0
let touchX = 0
let isDragging = false

const handleTouchStart = (e) => {
  touchStart = e.clientX || e.touches[0].clientX
  isDragging = true
}
const handleTouchMove = (e) => {
  if (!isDragging) return
  touchX = e.clientX || e.touches[0].clientX
  touchStart = touchX
  horizontalLinks.forEach(element => {
    element.style.transform = "translate(" + (touchStart) + "px," + "0px)";
  })
}
const handleTouchEnd = () => {
  isDragging = false
}


horizontalContainer.addEventListener('mousedown', handleTouchStart)
horizontalContainer.addEventListener('mousemove', handleTouchMove)
horizontalContainer.addEventListener('mouseleave', handleTouchEnd)
horizontalContainer.addEventListener('mouseup', handleTouchEnd)
horizontalContainer.addEventListener('selectstart', () => { return false })
.horizontal-container {
    display: flex;
    overflow-x: hidden;
    width: 100%;
}
  
.horizontal-container::-webkit-scrollbar {
    display: none;
}

.horizontal-link {
    padding-left: 20px;
    padding-right: 20px;
    right: 0;
}
    <div class="horizontal-container">
        <div class="horizontal-link">
            <a href="#">ONE</a>
        </div>
        <div class="horizontal-link">
            <a href="#">TWO</a>
        </div>
        <div class="horizontal-link">
            <a href="#">THREE</a>
        </div>
        <div class="horizontal-link">
            <a href="#">FOUR</a> 
        </div>
        <div class="horizontal-link">
            <a href="#">FIVE</a>   
        </div>                                    
    </div>

Edit: Unless you have the time to show me an example, I'm more than happy with just an explanation for how this can be done calculating translate: transform(x,y) to reposition the links when the left or right position of a link div of variable width reaches the right or left position of the screen depending on the screen width, which can also be variable, so that what the exact amount of overflow that peeks outside the viewport on the right will peek out the same amount on the left side of the viewport.

Edit2: Even though I know little about programming or the Javascript language (yet) I do know that this is not a "carousel" which is much easier to implement, that I already have created on my own so I know every detail of it. And a scrollbar is also programmed to move between a left end or right end position - this cannot be used here without a lot of ugly hacks so a new scrolling function needs to be implemented from scratch. I also know that jQuery will not help me to understand or learn more, and that this is nothing one would use - ever - whether you are an amateur or not.

2 Answers

What you are requesting is a carousel pattern. You can configure a carousel to show multiple slides at once. In this case each "slide" would be a menu item.

I have mocked up an example using https://kenwheeler.github.io/slick/

The only downfall is that it snaps to each slide, which may or may not be what you want. If it is not what you want then you would be best looking at other slider alternatives.

But the main point is that what you are requesting is normally done with a slider/carousel pattern. You just need to look at it differently, and you are not limited to show one "slide" at a time.

<link rel="stylesheet" type="text/css" href="//cdn.jsdelivr.net/npm/slick-carousel@1.8.1/slick/slick.css"/>

  <div class="menu-slider" style="width: 400px;">
    <div style="padding:20px;">Link 1</div>
    <div style="padding:20px;">Another Link 2</div>
    <div style="padding:20px;">Yet Another Link 3</div>
    <div style="padding:20px;">Menu Link 4</div>
    <div style="padding:20px;">Link 5</div>
    <div style="padding:20px;">Menu Link 6</div>
  </div>

<script type="text/javascript" src="https://code.jquery.com/jquery-1.11.0.min.js"></script>
<script type="text/javascript" src="https://code.jquery.com/jquery-migrate-1.2.1.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/slick-carousel@1.8.1/slick/slick.min.js"></script>

  <script type="text/javascript">
    $(document).ready(function(){
      $('.menu-slider').slick({
          dots: false,
          infinite: true,
          centerMode: false,
          variableWidth: true
      });
    });
  </script>

Here's a working solution for one that will "fill" its parent in order to create the effect.

I was working on an alternative that didn't fill but it still needs more work.

$(function () {
    init();
});

let base_width = 0;

function init() {
    setupMenu();
}

function handleScroll(event) {
    if (base_width === 0) {
        // no need to do anything
        return;
    }

    const $menu = $(event.currentTarget);
    const scroll_left = $menu[0].scrollLeft;

    // check backwards scroll
    if (scroll_left <= base_width) {
        const new_left = 2 * base_width - (base_width - scroll_left);
        $menu[0].scrollLeft = new_left;
        return;
    }

    if (scroll_left < base_width * 2) {
        return;
    }


    // get remainder
    const new_left = scroll_left % (base_width * 2);
    $menu[0].scrollLeft = new_left + base_width;
}

function setupMenu() {
    const $menu = $("#menu-fill");
    const $parent = $menu.parent();

    const menu_width = $menu.width();
    const parent_width = $parent.width();
    if (menu_width >= parent_width) {
        // no need to duplicate
        return;
    }

    base_width = menu_width;

    // setup a base to clone
    const $template = $menu.clone();

    // get num times to duplicate to "fill" menu (i.e. allow scrolling)
    // NOTE: we duplicate 1 "extra" so that we can scroll "backwards"
    const num_duplicate = Math.ceil(parent_width / menu_width) + 2;
    
    for (let i = 0; i < num_duplicate; i++) {
        const $new_menu = $template.clone();
        $menu.append($new_menu.children());
        $new_menu.remove();
    }

    $menu[0].scrollLeft = base_width;

    $menu.scroll(handleScroll)
}
* {
    box-sizing: border-box;
    font-family: sans-serif;
}

*::-webkit-scrollbar {
    background-color: transparent;
    height: 6px;
    width: 6px;
    border-radius: 3px;
}

*::-webkit-scrollbar-thumb {
    background-color: #ccc;
    border-radius: 3px;
}

html,
body {
    width: 100%;
    height: 100%;
    padding: 0;
    margin: 0;
}

.wrap {
    width: 250px;
    height: 100px;
    border: 2px solid #777;
    margin: 0 auto 20px auto;
}

.wrap.fill {
    width: 500px;
}

.menu {
    display: inline-flex;
    max-width: 100%;
    overflow: auto;
}

.item {
    padding: 5px 10px;
    background-color: #426ac0;
    color: #fff;
    border-right: 1px solid #1d3464;
    white-space: nowrap;
}

.item:last-child {
    border-right: none;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<div class="wrap fill">
    <div class="menu" id="menu-fill">
        <div class="item">Item 1</div>
        <div class="item">Number 2</div>
    </div>
</div>

Related