접근 가능한 일시정지 동작을 갖춘 자동 재생

전진 부분은 한 줄입니다 — apiRef를 통해 scrollNext()를 호출하는 타이머로, 같은 무한 루프 코어 위에 얹습니다. 공학의 본체는 언제 전진하지 않을지에 있습니다. 호버, 터치, 키보드 포커스, 일시정지 버튼, 숨은 탭, 화면 밖 레일, 동작 줄이기 설정 — 모두가 각각 다른 이유로 타이머를 멈춥니다.
Tokyo
Oslo
Lima
Cairo
Sydney
Quito
Seoul
Porto
Denver
Hanoi

레일에 호버, 터치, 또는 Tab으로 들어가면 일시정지합니다. 일시정지 버튼은 재생을 누를 때까지 멈춥니다.

동작 방식

useInterval(cb, active ? interval : null)이 스케줄러 전체입니다. active는 네 개의 플래그 — 사용자 일시정지, 호버 일시정지, 포커스 일시정지, prefers-reduced-motion — 를 묶고, null을 넘기면 타이머를 완전히 제거합니다. 그래서 재개하면 포인터가 막 떠난 직후 사이클 중간에 발화하는 대신, 새롭고 완전한 간격이 시작됩니다.

실행을 거부하는 틱

활성 타이머조차 스크롤 전에 확인합니다. 틱은 api.menuVisible.currentdocument.visibilityState를 읽고, 어느 하나가 아니오라면 건너뜁니다. 숨은 탭은 IntersectionObserver를 얼리므로, 거기서 스크롤하면 맹목적으로 전진하고 텔레포트 장부가 어긋납니다. 페이지 밖으로 스크롤된 레일은 애초에 움직이면 안 됩니다. 건너뛴 틱은 비용이 없습니다 — 다음 틱이 다시 확인합니다.

일시정지 표면

호버와 터치는 래퍼 핸들러로, 키보드 포커스는 onFocusCapture/onBlurCapture로, prefers-reduced-motion은 자동 재생을 완전히 꺼 둡니다. 명시적 일시정지 버튼이야말로 자동 전진 콘텐츠에 WCAG 2.2.2가 실제로 요구하는 것입니다 — 호버 일시정지만으로는 충족되지 않습니다.

참고

전체 소스

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

Autoplay.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,
  useInterval,
  useMediaQuery,
  useUnmount,
} from 'usehooks-ts';

// Autoplay on top of the InfiniteLoop recipe: a timer calls scrollNext()
// through apiRef, and the same useInfiniteLoop clone-and-teleport hook
// makes it endless. Pauses on hover, touch, focus and the Pause button,
// skips ticks in hidden tabs or when offscreen, and stays off under
// reduced motion (WCAG 2.2.2). The animation is the browser's native
// smooth scroll — `transitionDuration` has no effect with the default
// noPolyfill.

const CLONES_PER_SIDE = 6;

export function Autoplay({ interval = 2000 }: { interval?: number }) {
  // NOTE: for drag by mouse; the hover pause already covers dragging.
  const [dragManager] = React.useState(() => new DragDealer());

  const apiRef = React.useRef<publicApiType | null>(null);

  const loop = useInfiniteLoop(getItemIds());

  const reducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)', {
    initializeWithValue: false,
  });
  const [userPaused, setUserPaused] = React.useState(false);
  const [hoverPaused, setHoverPaused] = React.useState(false);
  const [focusPaused, setFocusPaused] = React.useState(false);

  const active = !userPaused && !hoverPaused && !focusPaused && !reducedMotion;

  // 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();
        }
      });

  // `null` removes the timer, so resuming starts a fresh, full interval.
  useInterval(
    () => {
      const api = apiRef.current;
      // A hidden tab freezes IntersectionObserver — skip, don't scroll blind.
      if (!api?.menuVisible.current || document.visibilityState !== 'visible') {
        return;
      }
      api.scrollNext();
    },
    active ? interval : null,
  );

  return (
    <div>
      <Toolbar>
        {/* Outside the hover wrapper, so clicking it can't hover-pause. */}
        <button
          type="button"
          data-testid="autoplay-toggle"
          onClick={() => setUserPaused((paused) => !paused)}
        >
          {userPaused ? 'Play' : 'Pause'}
        </button>
      </Toolbar>
      <NoScrollbar
        onMouseEnter={() => setHoverPaused(true)}
        onMouseLeave={() => {
          dragManager.dragStop();
          setHoverPaused(false);
        }}
        onTouchStart={() => setHoverPaused(true)}
        onTouchEnd={() => setHoverPaused(false)}
        onFocusCapture={() => setFocusPaused(true)}
        onBlurCapture={() => setFocusPaused(false)}
      >
        <ScrollMenu
          {...loop.menuProps}
          LeftArrow={LeftArrow}
          RightArrow={RightArrow}
          apiRef={apiRef}
          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}
            />
          ))}
        </ScrollMenu>
      </NoScrollbar>
    </div>
  );
}
export default Autoplay;

// 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.
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 Toolbar = styled('div')({
  display: 'flex',
  justifyContent: 'flex-end',
  marginBottom: '8px',
});

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({ realId, itemId }: { 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}
      data-testid="card"
      className="card"
      visible={isVisible}
    >
      <div className="header">
        <div>{realId}</div>
        <div className="visible">visible: {JSON.stringify(isVisible)}</div>
      </div>
      <div className="background" />
    </CardBody>
  );
}
const CardBody = styled('div')<{ 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: 'bisque',
    height: '200px',
  },
}));

관련 예제

예제 전체 21개