Skip to content

Instantly share code, notes, and snippets.

@xdmorgan
Last active September 8, 2019 20:05
Show Gist options
  • Save xdmorgan/7b5e10493d87d8f4fe18d55d76d79ac6 to your computer and use it in GitHub Desktop.
Save xdmorgan/7b5e10493d87d8f4fe18d55d76d79ac6 to your computer and use it in GitHub Desktop.
React Animated Marquee (CSS Module, Custom Hook, Accessible)
import React, { useRef, useState, useEffect } from 'react'
import cx from 'classnames'
import styles from './marquee-v2.module.scss'
export default function Marquee({
children,
className = undefined,
reverse = false,
...props
}) {
const [count, ref, width] = useMarquee()
React.Children.only(children)
const [child] = React.Children.toArray(children)
return (
<div
{...props}
className={cx(
styles.marquee,
{
[styles.animated]: count !== null,
[styles.reversed]: !!reverse,
},
className
)}
>
<div ref={ref} className={styles.marquee__measure} aria-hidden>
{children}
</div>
<div className={styles.marquee__spacer}>{children}</div>
<div className={styles.marquee__overflow}>
<div className={styles.marquee__elements} style={{ width }} aria-hidden>
{Array.from({ length: count }).map((_, idx) =>
React.cloneElement(child, {
...child.props,
key: `marqueev2-${idx}`,
style: { ...child.props.style, flex: '0 0 auto' },
})
)}
</div>
</div>
</div>
)
}
const getWidth = el => el.clientWidth
function fillContainer(el) {
// get the individual element width and the container width as basis
// for inFullView calculation
const [single, total] = [getWidth(el), getWidth(el.parentNode)]
// the floored number of elements completely visible in the container
const inFullView = Math.floor(total / single)
// FillGaps: add one so there is never an empty space left out by the
// inFullView calculation e.g. 100px card in 150px contaienr. There
// would be 1 in full view but then a 50px gap
const fillGaps = 1
// accountForAnimation: The animation pans the container of the repeated
// elements across the X access equal to the width of a single element
// in order to make sure there are no gaps whilst animating we'll need
// an additional 1 extra to make up for the one being animted offscreen.
const accountForAnimation = 1
// combine & return
return inFullView + fillGaps + accountForAnimation
}
function useMarquee() {
const ref = useRef()
const [count, setCount] = useState(null)
useEffect(() => {
let throttle
function onUpdate() {
clearTimeout(throttle)
if (ref && ref.current) {
throttle = setTimeout(() => setCount(fillContainer(ref.current)), 500)
}
}
onUpdate()
window.addEventListener('resize', onUpdate)
return () => {
clearTimeout(throttle)
window.removeEventListener('resize', onUpdate)
}
}, [ref])
return [count, ref, ref.current ? getWidth(ref.current) : null]
}
@xdmorgan
Copy link
Author

xdmorgan commented Sep 8, 2019

Required Styles (Sass Module)

.marquee {
  position: relative;

  @keyframes marquee-default {
    100% {
      transform: translate3d(-100%, 0, 0);
    }
  }
  @keyframes marquee-reverse {
    100% {
      transform: translate3d(100%, 0, 0);
    }
  }

  &__measure {
    // an element outside of the document flow to measure
    // and use as the basis for our repeated element in
    // the elements container
    position: absolute;
    visibility: hidden;
    height: auto;
    width: auto;
    white-space: nowrap;
  }
  &__spacer {
    // an invisible element used to prevent reflow jump
    // once the measurement element is repeated and rendered
    // across the screen. Also, the only version visible
    // from an a11y standpoint
    height: auto;
    width: auto;
    white-space: nowrap;
    opacity: 0;
    overflow: hidden;
  }
  &__overflow {
    // layer above the spacer taking up the same height footprint
    // prevents overflow issues with the intentionally overflowing
    // elements container within
    position: absolute;
    left: 0;
    top: 0;
    width: 100%;
    height: 100%;
    overflow: hidden;
    // setup flex positioning to support reversal
    display: flex;
  }
  &__elements {
    // repeat the element across the container as many times as necessary
    // within the overflow container, excess is hidden there
    display: flex;
    overflow: visible;
    opacity: 0;
    transition: opacity 500ms ease; // fade in when ready

    .animated & {
      opacity: 1;
      // duration animation with customizable duration the duration
      // is based on the time it takes for one element to loop
      // from x: 0 to x: -100%;
      animation-name: marquee-default;
      animation-timing-function: linear;
      animation-iteration-count: infinite;
      animation-duration: var(--marquee-loop-duration, 5s);
    }

    &:hover {
      // cool out when hovered
      animation-play-state: paused;
    }
  }

  &.reversed & {
    &__elements,
    &__overflow {
      justify-content: flex-end;
    }
    &__elements {
      animation-name: marquee-reverse;
    }
  }
}

@xdmorgan
Copy link
Author

xdmorgan commented Sep 8, 2019

Usage Example

import React from 'react'
import styles from './style.module.scss'
import { Marquee } from '../marquee'

export default function MarqueeExamples() {
  return (
    <>
      <Marquee>
        <div className={styles.cells}>
          <div className={styles.cell}>Check out  👀</div>
          <div className={styles.cell}>The demo  👀</div>
        </div>
      </Marquee>
      <Marquee reverse>
        <div className={styles.cells}>
          <div className={styles.cell}>Reverse  👀</div>
          <div className={styles.cell}>Direction  👀</div>
        </div>
      </Marquee>
    </>
  )
}
.cell {
  &s {
    display: flex;
  }

  font-family: 'Favorit Extended';
  font-weight: 500;
  font-size: 18px;
  line-height: 84.42%;
  display: flex;
  padding: 0;
  height: 40px;
  align-items: center;
  letter-spacing: -0.02em;
  text-transform: uppercase;
  color: var(--color-dark-navy);

  &:nth-child(odd) {
    color: var(--color-teal);
  }
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment