An endless, seamless scroll of logos, quotes or cards, with a set speed, direction and hover behavior.
pnpm dlx shadcn@latest add @latheui/marqueeFirst time? Run npx latheui init once to add the @latheui registry. The CLI then writes the source into your project and installs what it needs. Or add it by hand:
Install the npm dependencies.
pnpm add motionAdd the Lathe items this component imports.
lib/motion.tsCopy the source into your project.
"use client"
import * as React from "react"
import { motion, useAnimationFrame, useMotionValue, useTransform } from "motion/react"
import { cn } from "@/lib/utils"
import { useMotionSafe } from "@/lib/motion"
export type MarqueeProps = Omit<React.ComponentProps<"div">, "children"> & {
/** Anything: logos, names, cards. Each child is one item. */
children: React.ReactNode
/** Travel speed in pixels per second. */
speed?: number
direction?: "left" | "right" | "up" | "down"
/** What a hovering pointer or keyboard focus inside does. */
hover?: "pause" | "slow" | "none"
/** Fraction of the speed kept while slowed. */
slowFactor?: number
/** Space between items, as any CSS length. */
gap?: string
/** Fade the items out at both ends. */
fade?: boolean
/** Accessible name for the group of items. */
label?: string
}
const subscribeNever = () => () => {}
function Marquee({
children,
speed = 40,
direction = "left",
hover = "pause",
slowFactor = 0.25,
gap = "2rem",
fade = true,
label,
className,
style,
...props
}: MarqueeProps) {
const rootRef = React.useRef<HTMLDivElement>(null)
const copyRef = React.useRef<HTMLDivElement>(null)
const [copies, setCopies] = React.useState(2)
// The preference is unknown on the server, so the first client render ignores it too.
const hydrated = React.useSyncExternalStore(subscribeNever, () => true, () => false)
const reduced = useMotionSafe().reduced && hydrated
const horizontal = direction === "left" || direction === "right"
const forward = direction === "left" || direction === "up"
// Length of one copy along the travel axis, and how far the track has moved.
const length = React.useRef(0)
const offset = useMotionValue(0)
// Eased toward the target, so hovering slows the track down instead of jolting it.
const pace = React.useRef({ current: 1, target: 1 })
React.useEffect(() => {
const root = rootRef.current
const copy = copyRef.current
if (!root || !copy) return
const measure = () => {
const size = horizontal ? copy.offsetWidth : copy.offsetHeight
const room = horizontal ? root.offsetWidth : root.offsetHeight
length.current = size
// Enough copies to cover the container while one scrolls out of view.
if (size > 0) setCopies(Math.max(2, Math.ceil(room / size) + 1))
}
measure()
const observer = new ResizeObserver(measure)
observer.observe(root)
observer.observe(copy)
return () => observer.disconnect()
}, [horizontal])
useAnimationFrame((_, delta) => {
if (reduced || length.current === 0) return
// Skips of more than a few frames (a background tab) are not replayed.
const dt = Math.min(delta, 64)
const p = pace.current
p.current += (p.target - p.current) * (1 - Math.exp(-dt / 140))
offset.set((offset.get() + (speed * p.current * dt) / 1000) % length.current)
})
const transform = useTransform(offset, (value) => {
const shift = forward ? -value : value - length.current
return horizontal ? `translate3d(${shift}px, 0, 0)` : `translate3d(0, ${shift}px, 0)`
})
const setPace = (hovering: boolean) => {
pace.current.target = !hovering || hover === "none" ? 1 : hover === "pause" ? 0 : slowFactor
}
const edge = horizontal ? "to right" : "to bottom"
const mask = fade
? `linear-gradient(${edge}, transparent, black 12%, black 88%, transparent)`
: undefined
return (
<div
ref={rootRef}
data-slot="marquee"
role="group"
aria-label={label}
// Without motion it is a scrolling region, which must be reachable from the keyboard.
tabIndex={reduced ? 0 : undefined}
onPointerEnter={() => setPace(true)}
onPointerLeave={() => setPace(false)}
onFocus={() => setPace(true)}
onBlur={() => setPace(false)}
className={cn(
// Holding a moving item on a phone would otherwise start a text selection.
"flex overflow-hidden outline-none select-none [-webkit-touch-callout:none] focus-visible:ring-2 focus-visible:ring-ring",
horizontal ? "w-full flex-row" : "h-full flex-col",
// Without motion the items stay put, so let people scroll to the rest.
reduced && (horizontal ? "overflow-x-auto" : "overflow-y-auto"),
className
)}
style={{ maskImage: mask, WebkitMaskImage: mask, ...style }}
{...props}
>
<motion.div
className={cn("flex shrink-0", horizontal ? "flex-row" : "flex-col")}
style={{ transform: reduced ? undefined : transform }}
>
{Array.from({ length: reduced ? 1 : copies }, (_, i) => (
<div
key={i}
ref={i === 0 ? copyRef : undefined}
// Only the first copy is content. The rest are there to fill the loop.
aria-hidden={i > 0 || undefined}
inert={i > 0 || undefined}
className={cn("flex shrink-0 items-center", horizontal ? "flex-row" : "flex-col")}
style={{ gap, [horizontal ? "paddingRight" : "paddingBottom"]: gap }}
>
{children}
</div>
))}
</motion.div>
</div>
)
}
export { Marquee }Depends on
Also accepts native div attributes.
| Prop | Type | Default | Description |
|---|---|---|---|
| children* (required) | ReactNode | — | The items. Each child is one item; they are repeated as needed to fill the loop. |
| speed | number | 40 | Pixels per second. |
| direction | "left" | "right" | "up" | "down" | "left" | Direction of travel. |
| hover | "pause" | "slow" | "none" | "pause" | What a hovering pointer, or keyboard focus inside, does. |
| slowFactor | number | 0.25 | Share of the speed kept while slowed. |
| gap | string | "2rem" | Space between items. Any CSS length. |
| fade | boolean | true | Fades items out at both ends. |
| label | string | — | Accessible name for the group. |
<Marquee speed={80} direction="right" hover="slow" slowFactor={0.1}>
{items}
</Marquee><Marquee fade={false} gap="0.75rem">{items}</Marquee>