공개 API 위에 지은 무한 루프 메뉴

고전적인 복제-텔레포트 캐러셀 트릭을, 라이브러리 변경 없이 구현합니다. 행을 양 끝으로 복제하고, 스크롤이 복제 영역 안에서 안정되면 scrollLeft가 정확히 루프 한 바퀴만큼 점프합니다. 점프 양쪽의 프레임이 동일하므로 아무것도 움직인 것처럼 보이지 않습니다. 화살표, 휠, 터치, 마우스 드래그 모두 이음새를 건넙니다.
Tokyo
Oslo
Lima
Cairo
Sydney
Quito
Seoul
Porto
Denver
Hanoi

어느 방향으로든 계속 가세요 — 화살표, 휠, 터치, 드래그로 — 행은 결코 끝나지 않습니다.

동작 방식

getSlides는 항목을 행의 양 끝으로 복사합니다. itemId는 유일해야 하므로 복제에는 접미사가 붙습니다 — 왼쪽은 -lc, 오른쪽은 -rc — 한편 실제 id는 제목·선택·클릭을 위해 realId로 유지합니다. useInfiniteLoop가 나머지를 묶습니다. normalize()는 첫 실제 항목과 그 오른쪽 복제의 offsetLeft에서 루프 길이를 재고, 위치가 복제 영역에 들어갈 때마다 scrollLeft를 정확히 그 거리만큼 이동합니다. 순수 기하이고, 멱등입니다 — 고칠 것이 없을 때 호출해도 아무것도 하지 않습니다.

텔레포트가 발화하는 때

스크롤 중간의 점프는 브라우저와 눈에 띄게 싸우므로, normalize는 스크롤이 안정될 때 실행됩니다. 컨테이너의 네이티브 scrollend 리스너(containerRef 프로퍼티로 도달)에, scrollend를 발화하지 않는 Safari를 위한 150ms 디바운스 onScroll 폴백을 덧붙입니다. 누가 무엇을 보기 전에 점프가 하나 더 있습니다. 레이아웃 이펙트가 초기 scrollLeft를 페인트 전에 첫 실제 항목으로 맞춰, 페이지가 왼쪽 복제에서 열리는 일이 없습니다.

드래그 중 이음새 건너기

마우스 드래그 콜백은 각 델타를 scrollLeft에 더하고, 제스처 안에서 바로 그 자리에서 loop.normalize()를 호출합니다. 그것 없이는 복제 영역으로 드래그하면 텔레포트까지 드래그 끝을 기다려야 합니다 — 있으면, 이음새를 무한히 드래그해도 결코 눈치채지 못합니다.

참고

전체 소스

완전하고 복사-붙여넣기 가능 — 이것이 바로 그 파일입니다. 출처는, 이 라이브 편집 가능한 Storybook 버전.

InfiniteLoop.source.tsx
import 'react-horizontal-scrolling-menu/dist/styles.css';

import styled from '@emotion/styled';
import React from 'react';
import {
  type publicApiType,
  ScrollMenu,
  VisibilityContext,
} from 'react-horizontal-scrolling-menu';
import { useDebounceCallback, useUnmount } from 'usehooks-ts';

// Two pages per side: the clone zone must cover a viewport (identical
// frames around a jump), with room to spare so a Next click from the page
// straddling the seam never clamps at the end of the row.
const CLONES_PER_SIDE = 6;

export function InfiniteLoop() {
  const [selected, setSelected] = React.useState<string[]>([]);

  // NOTE: for drag by mouse
  const [dragManager] = React.useState(() => new DragDealer());

  const loop = useInfiniteLoop(getItemIds());

  // normalize() inside the drag keeps the seam crossable mid-gesture.
  const handleDrag =
    ({ scrollContainer }: publicApiType) =>
    (ev: React.MouseEvent) =>
      dragManager.dragMove(ev, (posDiff) => {
        if (scrollContainer.current) {
          scrollContainer.current.scrollLeft += posDiff;
          loop.normalize();
        }
      });

  const isItemSelected = (id: string): boolean => selected.includes(id);

  // Keyed by real id — clicking a clone selects its twin.
  const handleItemClick = (realId: string) => {
    if (dragManager.dragging) {
      return;
    }
    setSelected((currentSelected) =>
      currentSelected.includes(realId)
        ? currentSelected.filter((el) => el !== realId)
        : currentSelected.concat(realId),
    );
  };

  return (
    <NoScrollbar onMouseLeave={() => dragManager.dragStop()}>
      <ScrollMenu
        {...loop.menuProps}
        LeftArrow={LeftArrow}
        RightArrow={RightArrow}
        onMouseDown={() => dragManager.dragStart}
        onMouseUp={() => dragManager.dragStop}
        onMouseMove={handleDrag}
      >
        {loop.slides.map(({ itemId, realId }) => (
          <Card
            realId={realId}
            itemId={itemId} // NOTE: must be unique — clones get a suffix
            key={itemId}
            onClick={() => handleItemClick(realId)}
            selected={isItemSelected(realId)}
          />
        ))}
      </ScrollMenu>
    </NoScrollbar>
  );
}
export default InfiniteLoop;

// The loop, packaged: cloned slides, the pre-paint start jump and the
// seam teleport. Spread `menuProps` onto ScrollMenu, render `slides`,
// and call `normalize()` after moving scrollLeft by hand (e.g. inside a
// drag). `itemIds` are read once, on the first render.
function useInfiniteLoop(
  itemIds: string[],
  clonesPerSide: number = CLONES_PER_SIDE,
) {
  const [slides] = React.useState(() => getSlides(itemIds, clonesPerSide));

  // Receives the scroll container div itself.
  const containerRef = React.useRef<HTMLDivElement | null>(null);

  // Seam markers come from the data — itemId can be anything.
  const firstRealId = slides[clonesPerSide].itemId;
  const firstRightCloneId = slides[slides.length - clonesPerSide].itemId;

  // Shift by one loop length when settled inside a clone zone. Pure
  // geometry and idempotent — visibility flags lag and must not gate it.
  const normalize = React.useCallback(() => {
    const el = containerRef.current;
    const first = el?.querySelector<HTMLElement>(`[data-key='${firstRealId}']`);
    const firstClone = el?.querySelector<HTMLElement>(
      `[data-key='${firstRightCloneId}']`,
    );
    if (!el || !first || !firstClone) {
      return;
    }

    const realStart = first.offsetLeft;
    const loopLength = firstClone.offsetLeft - realStart;
    const x = el.scrollLeft;

    if (x >= realStart + loopLength) {
      el.scrollLeft = x - loopLength;
    } else if (x < realStart) {
      el.scrollLeft = x + loopLength;
    }
  }, [firstRealId, firstRightCloneId]);

  // 'scrollend' fires when scrolling truly ends; debounce covers Safari.
  const settle = useDebounceCallback(normalize, 150);
  useUnmount(() => settle.cancel());

  const hasScrollEnd = typeof window !== 'undefined' && 'onscrollend' in window;
  React.useEffect(() => {
    const el = containerRef.current;
    if (!el || !hasScrollEnd) {
      return;
    }
    el.addEventListener('scrollend', normalize);
    return () => el.removeEventListener('scrollend', normalize);
  }, [normalize, hasScrollEnd]);

  // Start on the first real item, before first paint.
  React.useLayoutEffect(() => {
    const el = containerRef.current;
    const first = el?.querySelector<HTMLElement>(`[data-key='${firstRealId}']`);
    if (el && first) {
      el.scrollLeft = first.offsetLeft;
    }
  }, [firstRealId]);

  return {
    slides,
    normalize,
    menuProps: {
      containerRef,
      onScroll: hasScrollEnd ? undefined : () => settle(),
    },
  };
}

const leftCloneId = (id: string) => `${id}-lc`;
const rightCloneId = (id: string) => `${id}-rc`;

// Clones render exactly like their twins; unique itemId is the only
// difference — title, selection and clicks all use the real id.
const getSlides = (ids: string[], clonesPerSide: number) => {
  const left = ids
    .slice(-clonesPerSide)
    .map((id) => ({ itemId: leftCloneId(id), realId: id }));
  const right = ids
    .slice(0, clonesPerSide)
    .map((id) => ({ itemId: rightCloneId(id), realId: id }));
  const real = ids.map((id) => ({ itemId: id, realId: id }));

  return [...left, ...real, ...right];
};

// An item is visible when any twin is: the raw per-element flag goes
// stale for a frame right after a teleport and would blink the header.
function useLoopItemVisible(realId: string) {
  const visibility = React.useContext<publicApiType>(VisibilityContext);
  const realVisible = visibility.useIsVisible(realId, true);
  const leftTwinVisible = visibility.useIsVisible(leftCloneId(realId), false);
  const rightTwinVisible = visibility.useIsVisible(rightCloneId(realId), false);
  return realVisible || leftTwinVisible || rightTwinVisible;
}

class DragDealer {
  clicked: boolean;
  dragging: boolean;
  position: number;
  resetId: number;

  constructor() {
    this.clicked = false;
    this.dragging = false;
    this.position = 0;
    this.resetId = 0;
  }

  public dragStart = (ev: React.MouseEvent) => {
    // A pending reset from the previous drag would kill this one.
    window.cancelAnimationFrame(this.resetId);
    this.position = ev.clientX;
    this.clicked = true;
  };

  public dragStop = () => {
    // Stop applying immediately; clear `dragging` a frame later so item
    // onClick (which fires after mouseup) still sees it and suppresses
    // the click.
    this.clicked = false;
    this.resetId = window.requestAnimationFrame(() => {
      this.dragging = false;
    });
  };

  public dragMove = (ev: React.MouseEvent, cb: (posDiff: number) => void) => {
    const newDiff = this.position - ev.clientX;

    if (this.clicked && Math.abs(newDiff) > 5) {
      this.dragging = true;
      this.position = ev.clientX;
      cb(newDiff);
    }
  };
}

const getId = (index: number) => `${'test'}${index}`;

const getItemIds = () =>
  Array(10)
    .fill(0)
    .map((_, ind) => getId(ind));

const NoScrollbar = styled('div')({
  '& .react-horizontal-scrolling-menu--scroll-container::-webkit-scrollbar': {
    display: 'none',
  },
  '& .react-horizontal-scrolling-menu--scroll-container': {
    scrollbarWidth: 'none',
    '-ms-overflow-style': 'none',
  },
});

// Always enabled: the stock arrow hooks track the outermost items — here
// those are clones, so they'd flash disabled at the seam.
function LeftArrow() {
  const visibility = React.useContext<publicApiType>(VisibilityContext);

  return (
    <Arrow onClick={() => visibility.scrollPrev()} testId="left-arrow">
      Left
    </Arrow>
  );
}

function RightArrow() {
  const visibility = React.useContext<publicApiType>(VisibilityContext);

  return (
    <Arrow onClick={() => visibility.scrollNext()} testId="right-arrow">
      Right
    </Arrow>
  );
}

function Arrow({
  children,
  onClick,
  testId,
}: {
  children: React.ReactNode;
  onClick: VoidFunction;
  testId: string;
}) {
  return (
    <ArrowButton onClick={onClick} data-testid={testId}>
      {children}
    </ArrowButton>
  );
}
const ArrowButton = styled('button')({
  cursor: 'pointer',
  display: 'flex',
  flexDirection: 'column',
  justifyContent: 'center',
  marginBottom: '2px',
  userSelect: 'none',
  borderRadius: '6px',
  borderWidth: '1px',
});

function Card({
  onClick,
  selected,
  realId,
  itemId,
}: {
  onClick: VoidFunction;
  selected: boolean;
  realId: string;
  itemId: string;
}) {
  const visibility = React.useContext<publicApiType>(VisibilityContext);
  // Raw flag of this element — kept on data-visible for the play tests.
  const ownVisible = visibility.useIsVisible(itemId, true);
  const isVisible = useLoopItemVisible(realId);

  return (
    <CardBody
      data-cy={itemId}
      data-visible={ownVisible}
      onClick={onClick}
      onKeyDown={(ev: React.KeyboardEvent) => {
        ev.code === 'Enter' && onClick();
      }}
      data-testid="card"
      role="button"
      tabIndex={0}
      className="card"
      visible={isVisible}
      selected={selected}
    >
      <div className="header">
        <div>{realId}</div>
        <div className="visible">visible: {JSON.stringify(isVisible)}</div>
        <div className="selected">selected: {JSON.stringify(!!selected)}</div>
      </div>
      <div className="background" />
    </CardBody>
  );
}
const CardBody = styled('div')<{ selected?: boolean; visible?: boolean }>(
  (props) => ({
    border: '1px solid',
    display: 'inline-block',
    margin: '0 10px',
    width: '160px',
    userSelect: 'none',
    borderRadius: '8px',
    overflow: 'hidden',

    '& .header': {
      backgroundColor: 'white',
    },

    '& .visible': {
      backgroundColor: props.visible ? 'transparent' : 'gray',
    },

    '& .background': {
      backgroundColor: props.selected ? 'green' : 'bisque',
      height: '200px',
    },
  }),
);

관련 예제

예제 전체 21개