feat(Stagger): staggered slide-in for list items

This commit is contained in:
Ilia Mashkov
2026-07-14 10:35:49 +03:00
parent 59304f4ae3
commit 7c6da0ca73
10 changed files with 188 additions and 24 deletions
+24
View File
@@ -483,6 +483,30 @@
}
}
/* Per-item slide-in reusing the section-body distance/easing. Stagger via
* --stagger-index set inline by the consumer. */
.stagger-in {
animation: stagger-in var(--duration-spring) var(--ease-spring) both;
animation-delay: calc(var(--stagger-index, 0) * var(--stagger-step, 50ms));
}
@keyframes stagger-in {
from {
opacity: 0;
transform: translateX(var(--slide-section-body-in));
}
to {
opacity: 1;
transform: translateX(0);
}
}
@media (prefers-reduced-motion: reduce) {
.stagger-in {
animation: none;
}
}
/* Page-load entry animation for the footer. Previously also carried
* `view-transition-name: site-footer` to layer above section-body's slide
* group, but the section-body group now has `animation: none` (purely
+10
View File
@@ -0,0 +1,10 @@
import 'react';
/**
* Allow CSS custom properties in `style` objects without casts.
*/
declare module 'react' {
interface CSSProperties {
[key: `--${string}`]: string | number | undefined;
}
}
+1
View File
@@ -0,0 +1 @@
export { Stagger } from './ui/Stagger';
@@ -0,0 +1,30 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
import { Badge } from '../../Badge';
import { Stagger } from './Stagger';
const meta: Meta<typeof Stagger> = {
title: 'Shared/Stagger',
component: Stagger,
};
export default meta;
type Story = StoryObj<typeof Stagger>;
const ITEMS = ['React', 'TypeScript', 'Next.js', 'Node', 'CSS', 'Vitest'];
/**
* Each item slides in from the right with a 50ms-per-index delay.
* Refresh the story to replay the entry animation.
*/
export const Staggered: Story = {
render: () => (
<div className="flex flex-wrap gap-3 p-8 bg-ochre-clay">
{ITEMS.map((item, i) => (
<Stagger key={item} index={i}>
<Badge>{item}</Badge>
</Stagger>
))}
</div>
),
};
+53
View File
@@ -0,0 +1,53 @@
import { render, screen } from '@testing-library/react';
import { Stagger } from './Stagger';
describe('Stagger', () => {
describe('rendering', () => {
it('renders children', () => {
render(<Stagger index={0}>Item</Stagger>);
expect(screen.getByText('Item')).toBeInTheDocument();
});
it('applies the stagger-in class', () => {
render(<Stagger index={0}>Item</Stagger>);
expect(screen.getByText('Item')).toHaveClass('stagger-in');
});
it('merges custom className', () => {
render(
<Stagger index={0} className="mt-4">
Item
</Stagger>,
);
expect(screen.getByText('Item')).toHaveClass('stagger-in', 'mt-4');
});
});
describe('stagger index', () => {
it('sets the --stagger-index custom property', () => {
render(<Stagger index={3}>Item</Stagger>);
expect(screen.getByText('Item').style.getPropertyValue('--stagger-index')).toBe('3');
});
it('sets index 0 for the first item', () => {
render(<Stagger index={0}>Item</Stagger>);
expect(screen.getByText('Item').style.getPropertyValue('--stagger-index')).toBe('0');
});
});
describe('step', () => {
it('omits --stagger-step by default', () => {
render(<Stagger index={1}>Item</Stagger>);
expect(screen.getByText('Item').style.getPropertyValue('--stagger-step')).toBe('');
});
it('sets --stagger-step in ms when provided', () => {
render(
<Stagger index={1} step={120}>
Item
</Stagger>,
);
expect(screen.getByText('Item').style.getPropertyValue('--stagger-step')).toBe('120ms');
});
});
});
+39
View File
@@ -0,0 +1,39 @@
import type { ReactNode } from 'react';
import { cn } from '$shared/lib';
interface Props {
/**
* Position in the list — drives the per-item animation-delay via the
* --stagger-index custom property consumed by the .stagger-in keyframe.
*/
index: number;
/**
* Item to animate in.
*/
children: ReactNode;
/**
* Per-item delay in ms. Bump it for few/large items (cards) so the
* stagger reads as sequential rather than simultaneous.
* @default 50
*/
step?: number;
/**
* Additional CSS classes merged onto the wrapper.
*/
className?: string;
}
/**
* Wraps a list item so it slides in from the right with a per-index delay.
* Reuses the section-body slide distance/easing; respects reduced-motion.
*/
export function Stagger({ index, children, step, className }: Props) {
return (
<div
className={cn('stagger-in', className)}
style={{ '--stagger-index': index, ...(step !== undefined && { '--stagger-step': `${step}ms` }) }}
>
{children}
</div>
);
}
+1
View File
@@ -13,5 +13,6 @@ export { Modal, type ModalHandle } from './Modal';
export { RichText } from './RichText';
export type { ContainerSize, SectionBackground } from './Section';
export { Container, Section } from './Section';
export { Stagger } from './Stagger';
export { TechStackBrick, TechStackGrid } from './TechStack';
export { ViewTransitionWrapper } from './ViewTransitionWrapper';
@@ -2,6 +2,7 @@ import { ExperienceCard } from '$entities/experience';
import type { ExperienceRecord } from '$shared/api';
import { getCollection } from '$shared/api';
import { formatMonthYearRange } from '$shared/lib';
import { Stagger } from '$shared/ui';
/**
* Experience section component.
@@ -15,15 +16,16 @@ export default async function ExperienceSection() {
return (
<div className="space-y-6 max-w-section">
{items.map((exp) => (
{items.map((exp, i) => (
<Stagger key={exp.id} index={i} step={240}>
<ExperienceCard
key={exp.id}
title={exp.role}
company={exp.company}
period={formatMonthYearRange(exp.start_date, exp.end_date)}
description={exp.description}
stack={exp.stack}
/>
</Stagger>
))}
</div>
);
@@ -2,6 +2,7 @@ import { ProjectCard } from '$entities/project';
import type { ProjectRecord } from '$shared/api';
import { getCollection } from '$shared/api';
import { buildFileUrl } from '$shared/lib';
import { Stagger } from '$shared/ui';
/**
* Projects section component.
@@ -21,8 +22,8 @@ export default async function ProjectsSection() {
return (
<div className="space-y-6 max-w-section">
{items.map((project, index) => (
<Stagger key={project.id} index={index} step={240}>
<ProjectCard
key={project.id}
title={project.title}
year={project.year}
description={project.description}
@@ -31,6 +32,7 @@ export default async function ProjectsSection() {
imageUrl={project.image ? buildFileUrl(project.collectionId, project.id, project.image) : undefined}
priority={index === lcpIndex}
/>
</Stagger>
))}
</div>
);
@@ -2,7 +2,7 @@ import { notFound } from 'next/navigation';
import type { SkillRecord } from '$shared/api';
import { getCollection } from '$shared/api';
import { groupByKey } from '$shared/lib';
import { Badge } from '$shared/ui';
import { Badge, Stagger } from '$shared/ui';
/**
* Skills section component.
@@ -24,10 +24,12 @@ export default async function SkillsSection() {
<div className="space-y-12 max-w-section">
{Object.entries(categories).map(([category, items]) => (
<div key={category} className="space-y-4">
<h3 className="text-xl font-bold uppercase tracking-widest opacity-60">{category}</h3>
<div className="flex flex-wrap gap-3">
{items.map((skill) => (
<Badge key={skill.id}>{skill.name}</Badge>
<h3 className="font-heading">{category}</h3>
<div className="flex flex-wrap gap-3 sm:max-w-golden">
{items.map((skill, i) => (
<Stagger key={skill.id} index={i}>
<Badge>{skill.name}</Badge>
</Stagger>
))}
</div>
</div>