Sequence
A component for creating timed sequences of content with automatic progression.
import { cn } from '@/lib/utils/classnames';
import { Sequence } from '../sequence';
import { eras as CONTENT } from './content';
const SequencePreview = () => {
return (
<Sequence className="relative w-160" loop duration={3000}>
<Sequence.Items className="flex gap-2 overflow-y-auto py-2">
{CONTENT.map((item, index) => (
<Sequence.Item
key={index}
className={cn(
'relative shrink-0 cursor-pointer overflow-hidden rounded-lg border border-background-secondary px-4 py-1 text-sm',
'flex items-center gap-1.5 whitespace-nowrap',
'transition-colors hover:bg-background-secondary/30',
'before:absolute before:inset-0 before:-z-10 before:bg-background-secondary before:content-[""]',
'before:origin-left before:scale-x-(--progress)'
)}
>
<item.icon size={16} className="-ml-1 shrink-0" />
{item.title}
</Sequence.Item>
))}
</Sequence.Items>
<Sequence.Panels className="relative h-64 w-full">
{CONTENT.map((item, index) => (
<Sequence.Panel key={index}>
<div className="absolute top-0 left-0 flex h-full min-h-64 flex-col justify-between rounded-lg border border-border p-4">
<div className="font-mono text-foreground-secondary text-sm uppercase">
{item.title}
</div>
<div className="text-pretty pr-8 font-medium text-xl">
{item.description}
</div>
</div>
</Sequence.Panel>
))}
</Sequence.Panels>
</Sequence>
);
};
export default SequencePreview; Dependencies
Source Code
import {
type ComponentPropsWithRef,
createContext,
type KeyboardEvent,
use,
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
} from 'react';
import {
InstanceCounterProvider,
useInstanceCounter,
} from '@/components/instance-counter';
import { Slot } from '@/components/slot';
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, 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>
);
};
const CompoundSequence = Object.assign(Sequence, {
Items: SequenceItems,
Item: SequenceItem,
Panels: SequencePanels,
Panel: SequencePanel,
});
export { CompoundSequence as Sequence }; Features
- Automatic Progression: Items advance automatically based on configurable durations
- Progress Indication: Exposes a
--progresscss variable to show progression through each item, and a--indexcss 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>
<Sequence.Items>
<Sequence.Item />
</Sequence.Items>
<Sequence.Panels>
<Sequence.Panel />
</Sequence.Panels>
</Sequence>
API Reference
Sequence
The root container component that manages the state and behavior of the sequence.
| Prop | Default | Type | Description |
|---|---|---|---|
duration * | - | number | number[] | The duration of the sequence in milliseconds. If an array is provided, it will be used to set the duration of each item individually. |
orientation | "horizontal" | "horizontal" | "vertical" | The orientation of the sequence. |
loop | false | boolean | Whether the sequence should loop. |
paused | false | boolean | Whether the sequence should be paused. |
currentIndex | - | number | The controlled index of the active item. |
onChange | - | (index: string) => void | Callback triggered when the active item changes. |
asChild | - | boolean | Whether to merge props onto the child element. |
Sequence.Items
The container for sequence items.
| Prop | Default | Type | Description |
|---|---|---|---|
asChild | - | boolean | Whether to merge props onto the child element. |
Sequence.Item
The individual sequence item button component.
| Prop | Default | Type | Description |
|---|---|---|---|
asChild | - | boolean | Whether to merge props onto the child element. |
Sequence.Panels
The container for sequence panels.
| Prop | Default | Type | Description |
|---|---|---|---|
asChild | - | boolean | Whether to merge props onto the child element. |
Sequence.Panel
The individual sequence panel component.
| Prop | Default | Type | Description |
|---|---|---|---|
forceMount | false | boolean | Whether to always mount the panel, regardless of whether it is active. |
asChild | - | boolean | Whether to merge props onto the child element. |
Examples
Vertical Orientation
import { Sequence } from '@/components/sequence';
import { cn } from '@/lib/utils/classnames';
import { eras as CONTENT } from './content';
const SequenceVertical = () => {
return (
<Sequence
orientation="vertical"
loop
className="flex max-w-lg gap-10"
duration={3000}
>
<div className="flex gap-2">
<div
className={cn(
'flex h-8 items-center transition-transform duration-300 ease-out',
'translate-y-[calc(var(--index)*100%)]'
)}
>
<div
className={cn(
'relative h-3 w-3 rounded-full text-foreground',
'before:absolute before:inset-0 before:bg-foreground/10',
"before:rounded-full before:content-['']"
)}
style={{
'--fill': 'calc(var(--progress) * 100%)',
background: `conic-gradient(from 0deg at 50% 50%, currentColor var(--fill), transparent var(--fill))`,
}}
/>
</div>
<Sequence.Items>
{CONTENT.map((item, index) => (
<Sequence.Item
key={index}
value={index.toString()}
className={cn(
'block h-8 text-left font-medium text-base',
'text-foreground-secondary data-[selected=true]:text-foreground',
'cursor-pointer hover:text-foreground/60 active:text-foreground/80'
)}
>
{item.title}
</Sequence.Item>
))}
</Sequence.Items>
</div>
<Sequence.Panels className="min-h-64">
{CONTENT.map((item, index) => (
<Sequence.Panel key={index} className="font-medium text-xl">
{item.description}
</Sequence.Panel>
))}
</Sequence.Panels>
</Sequence>
);
};
export default SequenceVertical; As Child
import { useState } from 'react';
import { Button } from '@/components/button';
import { Sequence } from '../sequence';
import { eras as CONTENT } from './content';
const SequencePreview = () => {
const [selectedIndex, setSelectedIndex] = useState<number>(0);
return (
<Sequence
className="relative w-160"
loop
onChange={(index) => setSelectedIndex(index)}
duration={3000}
>
<Sequence.Items className="flex gap-2 overflow-y-auto py-2">
{CONTENT.map((item, index) => (
<Sequence.Item key={index} asChild>
<Button
size="sm"
variant={selectedIndex === index ? 'primary' : 'outline'}
>
<item.icon size={16} className="-ml-1 shrink-0" />
{item.title}
</Button>
</Sequence.Item>
))}
</Sequence.Items>
<Sequence.Panels className="relative h-64 w-full">
{CONTENT.map((item, index) => (
<Sequence.Panel key={index}>
<div className="absolute top-0 left-0 flex h-full min-h-64 flex-col justify-between rounded-lg border border-border p-4">
<div className="font-mono text-foreground-secondary text-sm uppercase">
{item.title}
</div>
<div className="text-pretty pr-8 font-medium text-xl">
{item.description}
</div>
</div>
</Sequence.Panel>
))}
</Sequence.Panels>
</Sequence>
);
};
export default SequencePreview; Animate Presence
To make AnimatePresence work with Sequence.Panel and maintain accessibility, you need to jump through a few hoops. Because Sequence.Panel’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.
import { AnimatePresence, motion } from 'motion/react';
import { useState } from 'react';
import { Sequence } from '@/components/sequence';
import { cn } from '@/lib/utils/classnames';
import { eras as CONTENT } from './content';
const SequenceMotion = () => {
const [selectedIndex, setSelectedIndex] = useState<number>(0);
return (
<Sequence
loop
className="w-160"
onChange={(index) => setSelectedIndex(index)}
duration={3000}
>
<Sequence.Items className="flex w-full gap-2">
{CONTENT.map((item, index) => (
<Sequence.Item
key={index}
className={cn(
'relative shrink-0 cursor-pointer overflow-hidden rounded-lg border border-background-secondary px-4 py-1 text-sm',
'flex items-center gap-1.5 whitespace-nowrap',
'transition-colors hover:bg-background-secondary/30',
'before:absolute before:inset-0 before:-z-10 before:bg-background-secondary before:content-[""]',
'before:origin-left before:scale-x-(--progress)'
)}
>
<item.icon size={16} className="-ml-1 shrink-0" />
{item.title}
</Sequence.Item>
))}
</Sequence.Items>
<Sequence.Panels className="mt-4 grid overflow-hidden">
<AnimatePresence mode="wait" initial={false}>
{CONTENT.map((item, index) =>
index === selectedIndex ? (
<motion.div
key={index}
className="col-start-1 row-start-1"
initial={{ scale: 0.95 }}
animate={{ scale: 1 }}
exit={{ scale: 0.95 }}
transition={{ duration: 0.125 }}
>
<Sequence.Panel forceMount>
<div className="flex h-full min-h-64 flex-col justify-between rounded-lg border border-border p-4">
<motion.div
className="font-mono text-foreground-secondary text-sm uppercase"
initial={{ opacity: 0, y: '50%' }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 1.5,
delay: 0.15,
ease: [0.19, 1.0, 0.22, 1.0],
}}
>
{item.title}
</motion.div>
<motion.div
className="text-pretty pr-8 font-medium text-xl"
initial={{ opacity: 0, y: '50%' }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 1.5,
delay: 0.15,
ease: [0.19, 1.0, 0.22, 1.0],
}}
>
{item.description}
</motion.div>
</div>
</Sequence.Panel>
</motion.div>
) : (
<Sequence.Panel key={`${index}placeholder`} />
)
)}
</AnimatePresence>
</Sequence.Panels>
</Sequence>
);
};
export default SequenceMotion; Controlled
import { useState } from 'react';
import { Button } from '@/components/button';
import { cn } from '@/lib/utils/classnames';
import { Sequence } from '../sequence';
import { eras as CONTENT } from './content';
const SequenceControlled = () => {
const [currentIndex, setCurrentIndex] = useState(0);
return (
<div>
<div className="py-2">
<Button
size="sm"
onClick={() => setCurrentIndex((prev) => (prev + 1) % CONTENT.length)}
>
Next
</Button>
</div>
<Sequence
className="relative w-160"
loop
duration={3000}
currentIndex={currentIndex}
onChange={setCurrentIndex}
>
<Sequence.Items className="flex gap-2 overflow-y-auto py-2">
{CONTENT.map((item, index) => (
<Sequence.Item
key={index}
className={cn(
'relative shrink-0 cursor-pointer overflow-hidden rounded-lg border border-background-secondary px-4 py-1 text-sm',
'flex items-center gap-1.5 whitespace-nowrap',
'transition-colors hover:bg-background-secondary/30',
'before:absolute before:inset-0 before:-z-10 before:bg-background-secondary before:content-[""]',
'before:origin-left before:scale-x-(--progress)'
)}
>
<item.icon size={16} className="-ml-1 shrink-0" />
{item.title}
</Sequence.Item>
))}
</Sequence.Items>
<Sequence.Panels className="relative h-64 w-full">
{CONTENT.map((item, index) => (
<Sequence.Panel key={index}>
<div className="absolute top-0 left-0 flex h-full min-h-64 flex-col justify-between rounded-lg border border-border p-4">
<div className="font-mono text-foreground-secondary text-sm uppercase">
{item.title}
</div>
<div className="text-pretty pr-8 font-medium text-xl">
{item.description}
</div>
</div>
</Sequence.Panel>
))}
</Sequence.Panels>
</Sequence>
</div>
);
};
export default SequenceControlled; Pause on Hover
import { useState } from 'react';
import { cn } from '@/lib/utils/classnames';
import { Sequence } from '../sequence';
import { eras as CONTENT } from './content';
const SequencePauseHover = () => {
const [paused, setPaused] = useState(false);
return (
<Sequence className="relative w-160" loop paused={paused} duration={3000}>
<Sequence.Items
className="flex gap-2 overflow-y-auto py-2"
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
>
{CONTENT.map((item, index) => (
<Sequence.Item
key={index}
className={cn(
'relative shrink-0 cursor-pointer overflow-hidden rounded-lg border border-background-secondary px-4 py-1 text-sm',
'flex items-center gap-1.5 whitespace-nowrap',
'transition-colors hover:bg-background-secondary/30',
'before:absolute before:inset-0 before:-z-10 before:bg-background-secondary before:content-[""]',
'before:origin-left before:scale-x-(--progress)'
)}
>
<item.icon size={16} className="-ml-1 shrink-0" />
{item.title}
</Sequence.Item>
))}
</Sequence.Items>
<Sequence.Panels className="relative h-64 w-full">
{CONTENT.map((item, index) => (
<Sequence.Panel key={index}>
<div className="absolute top-0 left-0 flex h-full min-h-64 flex-col justify-between rounded-lg border border-border p-4">
<div className="font-mono text-foreground-secondary text-sm uppercase">
{item.title}
</div>
<div className="text-pretty pr-8 font-medium text-xl">
{item.description}
</div>
</div>
</Sequence.Panel>
))}
</Sequence.Panels>
</Sequence>
);
};
export default SequencePauseHover; 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.
import { useRef } from 'react';
import { scrollIntoViewIfNeeded } from '@/lib/dom/scroll-into-view-if-needed';
import { cn } from '@/lib/utils/classnames';
import { Sequence } from '../sequence';
import { erasExtended as CONTENT } from './content';
const SequenceScrollIntoView = () => {
const itemsScrollContainerRef = useRef<HTMLDivElement>(null);
return (
<Sequence
className="relative max-w-[min(90vw,640px)]"
loop
duration={3000}
onChange={(index) => {
if (itemsScrollContainerRef.current) {
const item = itemsScrollContainerRef.current.children[index];
if (item) {
scrollIntoViewIfNeeded(
itemsScrollContainerRef.current,
item as HTMLElement,
{
behavior: 'smooth',
}
);
}
}
}}
>
<Sequence.Items asChild>
<div
ref={itemsScrollContainerRef}
className="flex gap-2 overflow-y-auto py-2"
>
{CONTENT.map((item, index) => (
<Sequence.Item
key={index}
className={cn(
'relative shrink-0 cursor-pointer overflow-hidden rounded-lg border border-background-secondary px-4 py-1 text-sm',
'flex items-center gap-1.5 whitespace-nowrap',
'transition-colors hover:bg-background-secondary/30',
'before:absolute before:inset-0 before:-z-10 before:bg-background-secondary before:content-[""]',
'before:origin-left before:scale-x-(--progress)'
)}
>
<item.icon size={16} className="-ml-1 shrink-0" />
{item.title}
</Sequence.Item>
))}
</div>
</Sequence.Items>
<Sequence.Panels className="relative h-64 w-full">
{CONTENT.map((item, index) => (
<Sequence.Panel key={index}>
<div className="absolute top-0 left-0 flex h-full min-h-64 flex-col justify-between rounded-lg border border-border p-4">
<div className="font-mono text-foreground-secondary text-sm uppercase">
{item.title}
</div>
<div className="text-pretty pr-8 font-medium text-xl">
{item.description}
</div>
</div>
</Sequence.Panel>
))}
</Sequence.Panels>
</Sequence>
);
};
export default SequenceScrollIntoView; Previous
Marquee
Next
Slot