Sequence
A component for creating timed sequences of content with automatic progression.
Dependencies
Source Code
"use client";
import { Slot } from "@/components/slot";
import {
ComponentPropsWithRef,
createContext,
KeyboardEvent,
use,
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import {
InstanceCounterProvider,
useInstanceCounter,
} from "@/components/instance-counter";
import { useIntersectionObserver } from "@/foundations/hooks/use-intersection-observer";
import { useTicker } from "@/foundations/hooks/use-ticker";
import { clamp } from "@/lib/math/clamp";
// @types
type ItemState = "upcoming" | "current" | "past";
// @context
interface SequenceContext {
id: string;
orientation: "horizontal" | "vertical";
set: (index: number) => void;
next: () => void;
previous: () => void;
getItemState: (index: number) => ItemState;
getItemId: (index: number, role: "tab" | "tabpanel") => string;
setIsIntersecting: (isIntersecting: boolean) => void;
}
const SequenceContext = createContext<SequenceContext | null>(null);
const useSequenceContext = () => {
const context = use(SequenceContext);
if (!context)
throw new Error("Sequence components must be used within a Sequence");
return context;
};
// @root
interface SequenceProps extends Omit<ComponentPropsWithRef<"div">, "onChange"> {
currentIndex?: number;
asChild?: boolean;
loop?: boolean;
duration?: number | number[];
orientation?: "horizontal" | "vertical";
paused?: boolean;
onChange?: (index: number) => void;
}
const Sequence = ({
children,
asChild,
onChange,
loop,
orientation = "horizontal",
paused = false,
currentIndex: currentIndexProp,
duration,
...rest
}: SequenceProps) => {
const id = useId();
const ref = useRef<HTMLDivElement>(null);
const [currentIndex, setCurrentIndex] = useState(0);
const [numItems, setNumItems] = useState(0);
const durations = useMemo(() => {
return Array.isArray(duration)
? duration
: new Array(numItems).fill(duration);
}, [duration, numItems]);
const progress = useRef(0);
const [isIntersecting, setIsIntersecting] = useState(false);
const getItemId = useCallback(
(index: number, role: "tab" | "tabpanel") => [id, role, index].join("-"),
[id]
);
const getItemState = useCallback(
(index: number): ItemState => {
if (index === currentIndex) return "current";
if (index > currentIndex || index < 0) return "upcoming";
return "past";
},
[currentIndex]
);
const handleSetCurrentIndex = useCallback(
(index: number, preventOnChange: boolean = false) => {
setCurrentIndex(index);
if (!preventOnChange) {
onChange?.(index);
}
progress.current = 0;
if (paused) {
ref.current?.style.setProperty("--progress", "0");
}
if (isIntersecting && ref.current?.contains(document.activeElement)) {
const id = getItemId(index, "tab");
document.getElementById(id)?.focus();
}
},
[onChange, getItemId, isIntersecting, paused]
);
const next = useCallback(() => {
handleSetCurrentIndex((currentIndex + 1) % numItems);
}, [numItems, currentIndex, handleSetCurrentIndex]);
const previous = useCallback(() => {
handleSetCurrentIndex(currentIndex > 0 ? currentIndex - 1 : numItems - 1);
}, [numItems, currentIndex, handleSetCurrentIndex]);
const ticker = useTicker((_, delta) => {
if (!numItems) {
ref.current?.style.setProperty("--progress", "0");
return true;
}
const duration = durations[currentIndex] || 0;
progress.current = clamp(0, progress.current + delta / duration, 1);
ref.current?.style.setProperty("--progress", progress.current.toString());
if (progress.current === 1 && (loop || currentIndex < numItems - 1)) {
next();
}
return progress.current < 1;
});
// handle pause/play
useEffect(() => {
if (isIntersecting && ticker.paused && !paused) {
ticker.start();
}
if ((!isIntersecting || paused) && !ticker.paused) {
ticker.stop();
}
}, [isIntersecting, ticker, currentIndex, paused]);
// handle external control
useEffect(() => {
if (currentIndexProp !== undefined && currentIndexProp !== currentIndex) {
handleSetCurrentIndex(currentIndexProp, true);
}
}, [handleSetCurrentIndex, currentIndexProp, currentIndex]);
const Comp = asChild ? Slot : "div";
return (
<SequenceContext.Provider
value={{
id,
orientation,
set: handleSetCurrentIndex,
getItemState,
getItemId,
next,
previous,
setIsIntersecting,
}}
>
<InstanceCounterProvider onChange={setNumItems}>
<Comp
{...rest}
ref={ref}
style={{ "--progress": 0, "--index": currentIndex, ...rest.style }}
>
{children}
</Comp>
</InstanceCounterProvider>
</SequenceContext.Provider>
);
};
// @items
interface SequenceItemsProps extends ComponentPropsWithRef<"div"> {
asChild?: boolean;
}
const SequenceItems = ({ children, asChild, ...rest }: SequenceItemsProps) => {
const { orientation, setIsIntersecting } = useSequenceContext();
const [ref] = useIntersectionObserver<HTMLDivElement>({}, setIsIntersecting);
const Comp = asChild ? Slot : "div";
return (
<Comp
{...rest}
ref={ref}
role="tablist"
aria-orientation={orientation}
aria-live="polite"
>
{children}
</Comp>
);
};
// @item
interface SequenceItemProps extends ComponentPropsWithRef<"button"> {
asChild?: boolean;
}
const SequenceItem = ({
children,
asChild,
onClick,
onKeyDown,
...rest
}: SequenceItemProps) => {
const { orientation, set, next, previous, getItemState, getItemId } =
useSequenceContext();
const index = useInstanceCounter();
const state = getItemState(index);
const handleKeyboardNavigation = (e: KeyboardEvent<HTMLButtonElement>) => {
if (e.key === "Enter" || e.key === " ") {
set(index);
}
const keyOrientationMap = {
horizontal: { next: "ArrowRight", prev: "ArrowLeft" },
vertical: { next: "ArrowDown", prev: "ArrowUp" },
};
if (keyOrientationMap[orientation].next === e.key) {
e.preventDefault();
next();
}
if (keyOrientationMap[orientation].prev === e.key) {
e.preventDefault();
previous();
}
};
const Comp = asChild ? Slot : "button";
return (
<Comp
{...rest}
id={getItemId(index, "tab")}
role="tab"
aria-selected={state === "current"}
aria-controls={getItemId(index, "tabpanel")}
data-state={state}
data-selected={state === "current"}
tabIndex={state === "current" ? 0 : -1}
style={{
...(state !== "current" && { "--progress": +(state === "past") }),
...rest.style,
}}
onClick={(e) => {
onClick?.(e);
if (!e.defaultPrevented) set(index);
}}
onKeyDown={(e) => {
onKeyDown?.(e);
if (!e.defaultPrevented) handleKeyboardNavigation(e);
}}
>
{children}
</Comp>
);
};
// @panels
interface SequencePanelsProps extends ComponentPropsWithRef<"div"> {
asChild?: boolean;
}
const SequencePanels = ({
children,
asChild,
...rest
}: SequencePanelsProps) => {
const Comp = asChild ? Slot : "div";
return (
<Comp {...rest}>
<InstanceCounterProvider>{children}</InstanceCounterProvider>
</Comp>
);
};
// @panel
interface SequencePanelProps extends ComponentPropsWithRef<"div"> {
asChild?: boolean;
forceMount?: boolean;
}
const SequencePanel = ({
children,
asChild,
forceMount,
...rest
}: SequencePanelProps) => {
const { getItemState, getItemId } = useSequenceContext();
const index = useInstanceCounter();
const state = getItemState(index);
const Comp = asChild ? Slot : "div";
return (
<Comp
{...rest}
id={getItemId(index, "tabpanel")}
data-state={state}
data-selected={state === "current"}
role="tabpanel"
aria-labelledby={getItemId(index, "tab")}
aria-hidden={state !== "current"}
inert={state !== "current"}
style={{
...(state !== "current" && { "--progress": +(state === "past") }),
...rest.style,
}}
>
{forceMount || state === "current" ? children : null}
</Comp>
);
};
export { Sequence, SequenceItem, SequencePanels, SequencePanel, SequenceItems };
Features
- Automatic Progression: Items advance automatically based on configurable durations
- Progress Indication: Exposes a
--progress
css variable to show progression through each item, and a--index
css variable to show the current index - Keyboard Navigation: Full keyboard support for manual navigation
- ARIA Support: Full accessibility implementation with proper ARIA attributes
- Unstyled: No default styles, just the sequence behavior
- Intersection Observer: Pauses progression when not in viewport
Anatomy
<Sequence>
<SequenceItems>
<SequenceItem />
</SequenceItems>
<SequencePanels>
<SequencePanel />
</SequencePanels>
</Sequence>
API Reference
Sequence
The root container component that manages the state and behavior of the sequence.
Prop | Default | Type | Description |
---|---|---|---|
| - |
| The duration of the sequence in milliseconds. If an array is provided, it will be used to set the duration of each item individually. |
|
|
| The orientation of the sequence. |
|
|
| Whether the sequence should loop. |
|
|
| Whether the sequence should be paused. |
| - |
| The controlled index of the active item. |
| - |
| Callback triggered when the active item changes. |
| - |
| Whether to merge props onto the child element. |
SequenceItems
The container for sequence items.
Prop | Default | Type | Description |
---|---|---|---|
| - |
| Whether to merge props onto the child element. |
SequenceItem
The individual sequence item button component.
Prop | Default | Type | Description |
---|---|---|---|
| - |
| Whether to merge props onto the child element. |
SequencePanels
The container for sequence panels.
Prop | Default | Type | Description |
---|---|---|---|
| - |
| Whether to merge props onto the child element. |
SequencePanel
The individual sequence panel component.
Prop | Default | Type | Description |
---|---|---|---|
|
|
| Whether to always mount the panel, regardless of whether it is active. |
| - |
| Whether to merge props onto the child element. |
Examples
Vertical Orientation
As Child
Animate Presence
To make AnimatePresence
work with SequencePanel
and maintain accessibility, you need to jump through a few hoops. Because SequencePanel
's conditional rendering is only applied to its children, you need to force the panel to mount and unmount with a different key to trigger AnimatePresence
's exit
animation. This example demonstrates how you would do it.
Controlled
Pause on Hover
Scroll Into View
The root Sequence
component exposes an elements
argument in its onChange
prop that allows you to manipulate the active items. This example demonstrates how to scroll the active item into view.