From 7c6da0ca7369c44290ab9868bb8da110d7bdc00b Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Tue, 14 Jul 2026 10:35:49 +0300 Subject: [PATCH] feat(Stagger): staggered slide-in for list items --- src/shared/styles/theme.css | 24 +++++++++ src/shared/types/react-css.d.ts | 10 ++++ src/shared/ui/Stagger/index.ts | 1 + src/shared/ui/Stagger/ui/Stagger.stories.tsx | 30 +++++++++++ src/shared/ui/Stagger/ui/Stagger.test.tsx | 53 +++++++++++++++++++ src/shared/ui/Stagger/ui/Stagger.tsx | 39 ++++++++++++++ src/shared/ui/index.ts | 1 + .../ExperienceSection/ExperienceSection.tsx | 20 +++---- .../ui/ProjectsSection/ProjectsSection.tsx | 22 ++++---- .../ui/SkillsSection/SkillsSection.tsx | 12 +++-- 10 files changed, 188 insertions(+), 24 deletions(-) create mode 100644 src/shared/types/react-css.d.ts create mode 100644 src/shared/ui/Stagger/index.ts create mode 100644 src/shared/ui/Stagger/ui/Stagger.stories.tsx create mode 100644 src/shared/ui/Stagger/ui/Stagger.test.tsx create mode 100644 src/shared/ui/Stagger/ui/Stagger.tsx diff --git a/src/shared/styles/theme.css b/src/shared/styles/theme.css index aa13707..f9fed13 100644 --- a/src/shared/styles/theme.css +++ b/src/shared/styles/theme.css @@ -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 diff --git a/src/shared/types/react-css.d.ts b/src/shared/types/react-css.d.ts new file mode 100644 index 0000000..e7ac750 --- /dev/null +++ b/src/shared/types/react-css.d.ts @@ -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; + } +} diff --git a/src/shared/ui/Stagger/index.ts b/src/shared/ui/Stagger/index.ts new file mode 100644 index 0000000..3a7c4d7 --- /dev/null +++ b/src/shared/ui/Stagger/index.ts @@ -0,0 +1 @@ +export { Stagger } from './ui/Stagger'; diff --git a/src/shared/ui/Stagger/ui/Stagger.stories.tsx b/src/shared/ui/Stagger/ui/Stagger.stories.tsx new file mode 100644 index 0000000..c01fbab --- /dev/null +++ b/src/shared/ui/Stagger/ui/Stagger.stories.tsx @@ -0,0 +1,30 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { Badge } from '../../Badge'; +import { Stagger } from './Stagger'; + +const meta: Meta = { + title: 'Shared/Stagger', + component: Stagger, +}; + +export default meta; + +type Story = StoryObj; + +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: () => ( +
+ {ITEMS.map((item, i) => ( + + {item} + + ))} +
+ ), +}; diff --git a/src/shared/ui/Stagger/ui/Stagger.test.tsx b/src/shared/ui/Stagger/ui/Stagger.test.tsx new file mode 100644 index 0000000..54b3b28 --- /dev/null +++ b/src/shared/ui/Stagger/ui/Stagger.test.tsx @@ -0,0 +1,53 @@ +import { render, screen } from '@testing-library/react'; +import { Stagger } from './Stagger'; + +describe('Stagger', () => { + describe('rendering', () => { + it('renders children', () => { + render(Item); + expect(screen.getByText('Item')).toBeInTheDocument(); + }); + + it('applies the stagger-in class', () => { + render(Item); + expect(screen.getByText('Item')).toHaveClass('stagger-in'); + }); + + it('merges custom className', () => { + render( + + Item + , + ); + expect(screen.getByText('Item')).toHaveClass('stagger-in', 'mt-4'); + }); + }); + + describe('stagger index', () => { + it('sets the --stagger-index custom property', () => { + render(Item); + expect(screen.getByText('Item').style.getPropertyValue('--stagger-index')).toBe('3'); + }); + + it('sets index 0 for the first item', () => { + render(Item); + expect(screen.getByText('Item').style.getPropertyValue('--stagger-index')).toBe('0'); + }); + }); + + describe('step', () => { + it('omits --stagger-step by default', () => { + render(Item); + expect(screen.getByText('Item').style.getPropertyValue('--stagger-step')).toBe(''); + }); + + it('sets --stagger-step in ms when provided', () => { + render( + + Item + , + ); + expect(screen.getByText('Item').style.getPropertyValue('--stagger-step')).toBe('120ms'); + }); + }); +}); diff --git a/src/shared/ui/Stagger/ui/Stagger.tsx b/src/shared/ui/Stagger/ui/Stagger.tsx new file mode 100644 index 0000000..c4f5b8e --- /dev/null +++ b/src/shared/ui/Stagger/ui/Stagger.tsx @@ -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 ( +
+ {children} +
+ ); +} diff --git a/src/shared/ui/index.ts b/src/shared/ui/index.ts index b8ec55c..63d5a65 100644 --- a/src/shared/ui/index.ts +++ b/src/shared/ui/index.ts @@ -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'; diff --git a/src/widgets/ExperienceSection/ui/ExperienceSection/ExperienceSection.tsx b/src/widgets/ExperienceSection/ui/ExperienceSection/ExperienceSection.tsx index 3becc5f..45f2287 100644 --- a/src/widgets/ExperienceSection/ui/ExperienceSection/ExperienceSection.tsx +++ b/src/widgets/ExperienceSection/ui/ExperienceSection/ExperienceSection.tsx @@ -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 (
- {items.map((exp) => ( - + {items.map((exp, i) => ( + + + ))}
); diff --git a/src/widgets/ProjectsSection/ui/ProjectsSection/ProjectsSection.tsx b/src/widgets/ProjectsSection/ui/ProjectsSection/ProjectsSection.tsx index 97da991..47fdaea 100644 --- a/src/widgets/ProjectsSection/ui/ProjectsSection/ProjectsSection.tsx +++ b/src/widgets/ProjectsSection/ui/ProjectsSection/ProjectsSection.tsx @@ -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,16 +22,17 @@ export default async function ProjectsSection() { return (
{items.map((project, index) => ( - + + + ))}
); diff --git a/src/widgets/SkillsSection/ui/SkillsSection/SkillsSection.tsx b/src/widgets/SkillsSection/ui/SkillsSection/SkillsSection.tsx index 9c729c5..a704d35 100644 --- a/src/widgets/SkillsSection/ui/SkillsSection/SkillsSection.tsx +++ b/src/widgets/SkillsSection/ui/SkillsSection/SkillsSection.tsx @@ -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() {
{Object.entries(categories).map(([category, items]) => (
-

{category}

-
- {items.map((skill) => ( - {skill.name} +

{category}

+
+ {items.map((skill, i) => ( + + {skill.name} + ))}