Sequence
A component for creating timed sequences of content with automatic progression.
'use client';
import { cn } from '@/lib/utils/classnames';
import {
Sequence,
SequenceItem,
SequenceItems,
SequencePanel,
SequencePanels,
} from '../sequence';
import { eras as CONTENT } from './content';
const SequencePreview = () => {
return (
<Sequence className="relative w-160" loop duration={3000}>
<SequenceItems className="flex gap-2 overflow-y-auto py-2">
{CONTENT.map((item, index) => (
<SequenceItem
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}
</SequenceItem>
))}
</SequenceItems>
<SequencePanels className="relative h-64 w-full">
{CONTENT.map((item, index) => (
<SequencePanel 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>
</SequencePanel>
))}
</SequencePanels>
</Sequence>
);
};
export default SequencePreview; Dependencies
Source Code
'use client';
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>
);
};
export { Sequence, SequenceItem, SequenceItems, SequencePanel, SequencePanels }; 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>
<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 |
|---|---|---|---|
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. |
SequenceItems
The container for sequence items.
| Prop | Default | Type | Description |
|---|---|---|---|
asChild | - | boolean | Whether to merge props onto the child element. |
SequenceItem
The individual sequence item button component.
| Prop | Default | Type | Description |
|---|---|---|---|
asChild | - | boolean | Whether to merge props onto the child element. |
SequencePanels
The container for sequence panels.
| Prop | Default | Type | Description |
|---|---|---|---|
asChild | - | boolean | Whether to merge props onto the child element. |
SequencePanel
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
'use client';
import {
Sequence,
SequenceItem,
SequenceItems,
SequencePanel,
SequencePanels,
} 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>
<SequenceItems>
{CONTENT.map((item, index) => (
<SequenceItem
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}
</SequenceItem>
))}
</SequenceItems>
</div>
<SequencePanels className="min-h-64">
{CONTENT.map((item, index) => (
<SequencePanel key={index} className="font-medium text-xl">
{item.description}
</SequencePanel>
))}
</SequencePanels>
</Sequence>
);
};
export default SequenceVertical; As Child
'use client';
import { useState } from 'react';
import { Button } from '@/components/button';
import {
Sequence,
SequenceItem,
SequenceItems,
SequencePanel,
SequencePanels,
} 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}
>
<SequenceItems className="flex gap-2 overflow-y-auto py-2">
{CONTENT.map((item, index) => (
<SequenceItem key={index} asChild>
<Button
size="sm"
variant={selectedIndex === index ? 'primary' : 'outline'}
>
<item.icon size={16} className="-ml-1 shrink-0" />
{item.title}
</Button>
</SequenceItem>
))}
</SequenceItems>
<SequencePanels className="relative h-64 w-full">
{CONTENT.map((item, index) => (
<SequencePanel 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>
</SequencePanel>
))}
</SequencePanels>
</Sequence>
);
};
export default SequencePreview; 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.
'use client';
import { AnimatePresence, motion } from 'motion/react';
import { useState } from 'react';
import {
Sequence,
SequenceItem,
SequenceItems,
SequencePanel,
SequencePanels,
} 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}
>
<SequenceItems className="flex w-full gap-2">
{CONTENT.map((item, index) => (
<SequenceItem
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}
</SequenceItem>
))}
</SequenceItems>
<SequencePanels 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 }}
>
<SequencePanel 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>
</SequencePanel>
</motion.div>
) : (
<SequencePanel key={`${index}placeholder`} />
)
)}
</AnimatePresence>
</SequencePanels>
</Sequence>
);
};
export default SequenceMotion; Controlled
'use client';
import { useState } from 'react';
import { Button } from '@/components/button';
import { cn } from '@/lib/utils/classnames';
import {
Sequence,
SequenceItem,
SequenceItems,
SequencePanel,
SequencePanels,
} 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}
>
<SequenceItems className="flex gap-2 overflow-y-auto py-2">
{CONTENT.map((item, index) => (
<SequenceItem
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}
</SequenceItem>
))}
</SequenceItems>
<SequencePanels className="relative h-64 w-full">
{CONTENT.map((item, index) => (
<SequencePanel 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>
</SequencePanel>
))}
</SequencePanels>
</Sequence>
</div>
);
};
export default SequenceControlled; Pause on Hover
'use client';
import { useState } from 'react';
import { cn } from '@/lib/utils/classnames';
import {
Sequence,
SequenceItem,
SequenceItems,
SequencePanel,
SequencePanels,
} 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}>
<SequenceItems
className="flex gap-2 overflow-y-auto py-2"
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
>
{CONTENT.map((item, index) => (
<SequenceItem
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}
</SequenceItem>
))}
</SequenceItems>
<SequencePanels className="relative h-64 w-full">
{CONTENT.map((item, index) => (
<SequencePanel 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>
</SequencePanel>
))}
</SequencePanels>
</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.
'use client';
import { useRef } from 'react';
import { scrollIntoViewIfNeeded } from '@/lib/dom/scroll-into-view-if-needed';
import { cn } from '@/lib/utils/classnames';
import {
Sequence,
SequenceItem,
SequenceItems,
SequencePanel,
SequencePanels,
} 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',
}
);
}
}
}}
>
<SequenceItems asChild>
<div
ref={itemsScrollContainerRef}
className="flex gap-2 overflow-y-auto py-2"
>
{CONTENT.map((item, index) => (
<SequenceItem
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}
</SequenceItem>
))}
</div>
</SequenceItems>
<SequencePanels className="relative h-64 w-full">
{CONTENT.map((item, index) => (
<SequencePanel 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>
</SequencePanel>
))}
</SequencePanels>
</Sequence>
);
};
export default SequenceScrollIntoView; Previous
Marquee
Next
Slot