feat(Stagger): staggered slide-in for list items
This commit is contained in:
@@ -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
|
/* Page-load entry animation for the footer. Previously also carried
|
||||||
* `view-transition-name: site-footer` to layer above section-body's slide
|
* `view-transition-name: site-footer` to layer above section-body's slide
|
||||||
* group, but the section-body group now has `animation: none` (purely
|
* group, but the section-body group now has `animation: none` (purely
|
||||||
|
|||||||
Vendored
+10
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
),
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,5 +13,6 @@ export { Modal, type ModalHandle } from './Modal';
|
|||||||
export { RichText } from './RichText';
|
export { RichText } from './RichText';
|
||||||
export type { ContainerSize, SectionBackground } from './Section';
|
export type { ContainerSize, SectionBackground } from './Section';
|
||||||
export { Container, Section } from './Section';
|
export { Container, Section } from './Section';
|
||||||
|
export { Stagger } from './Stagger';
|
||||||
export { TechStackBrick, TechStackGrid } from './TechStack';
|
export { TechStackBrick, TechStackGrid } from './TechStack';
|
||||||
export { ViewTransitionWrapper } from './ViewTransitionWrapper';
|
export { ViewTransitionWrapper } from './ViewTransitionWrapper';
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { ExperienceCard } from '$entities/experience';
|
|||||||
import type { ExperienceRecord } from '$shared/api';
|
import type { ExperienceRecord } from '$shared/api';
|
||||||
import { getCollection } from '$shared/api';
|
import { getCollection } from '$shared/api';
|
||||||
import { formatMonthYearRange } from '$shared/lib';
|
import { formatMonthYearRange } from '$shared/lib';
|
||||||
|
import { Stagger } from '$shared/ui';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Experience section component.
|
* Experience section component.
|
||||||
@@ -15,15 +16,16 @@ export default async function ExperienceSection() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-section">
|
<div className="space-y-6 max-w-section">
|
||||||
{items.map((exp) => (
|
{items.map((exp, i) => (
|
||||||
|
<Stagger key={exp.id} index={i} step={240}>
|
||||||
<ExperienceCard
|
<ExperienceCard
|
||||||
key={exp.id}
|
|
||||||
title={exp.role}
|
title={exp.role}
|
||||||
company={exp.company}
|
company={exp.company}
|
||||||
period={formatMonthYearRange(exp.start_date, exp.end_date)}
|
period={formatMonthYearRange(exp.start_date, exp.end_date)}
|
||||||
description={exp.description}
|
description={exp.description}
|
||||||
stack={exp.stack}
|
stack={exp.stack}
|
||||||
/>
|
/>
|
||||||
|
</Stagger>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { ProjectCard } from '$entities/project';
|
|||||||
import type { ProjectRecord } from '$shared/api';
|
import type { ProjectRecord } from '$shared/api';
|
||||||
import { getCollection } from '$shared/api';
|
import { getCollection } from '$shared/api';
|
||||||
import { buildFileUrl } from '$shared/lib';
|
import { buildFileUrl } from '$shared/lib';
|
||||||
|
import { Stagger } from '$shared/ui';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Projects section component.
|
* Projects section component.
|
||||||
@@ -21,8 +22,8 @@ export default async function ProjectsSection() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-section">
|
<div className="space-y-6 max-w-section">
|
||||||
{items.map((project, index) => (
|
{items.map((project, index) => (
|
||||||
|
<Stagger key={project.id} index={index} step={240}>
|
||||||
<ProjectCard
|
<ProjectCard
|
||||||
key={project.id}
|
|
||||||
title={project.title}
|
title={project.title}
|
||||||
year={project.year}
|
year={project.year}
|
||||||
description={project.description}
|
description={project.description}
|
||||||
@@ -31,6 +32,7 @@ export default async function ProjectsSection() {
|
|||||||
imageUrl={project.image ? buildFileUrl(project.collectionId, project.id, project.image) : undefined}
|
imageUrl={project.image ? buildFileUrl(project.collectionId, project.id, project.image) : undefined}
|
||||||
priority={index === lcpIndex}
|
priority={index === lcpIndex}
|
||||||
/>
|
/>
|
||||||
|
</Stagger>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { notFound } from 'next/navigation';
|
|||||||
import type { SkillRecord } from '$shared/api';
|
import type { SkillRecord } from '$shared/api';
|
||||||
import { getCollection } from '$shared/api';
|
import { getCollection } from '$shared/api';
|
||||||
import { groupByKey } from '$shared/lib';
|
import { groupByKey } from '$shared/lib';
|
||||||
import { Badge } from '$shared/ui';
|
import { Badge, Stagger } from '$shared/ui';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Skills section component.
|
* Skills section component.
|
||||||
@@ -24,10 +24,12 @@ export default async function SkillsSection() {
|
|||||||
<div className="space-y-12 max-w-section">
|
<div className="space-y-12 max-w-section">
|
||||||
{Object.entries(categories).map(([category, items]) => (
|
{Object.entries(categories).map(([category, items]) => (
|
||||||
<div key={category} className="space-y-4">
|
<div key={category} className="space-y-4">
|
||||||
<h3 className="text-xl font-bold uppercase tracking-widest opacity-60">{category}</h3>
|
<h3 className="font-heading">{category}</h3>
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3 sm:max-w-golden">
|
||||||
{items.map((skill) => (
|
{items.map((skill, i) => (
|
||||||
<Badge key={skill.id}>{skill.name}</Badge>
|
<Stagger key={skill.id} index={i}>
|
||||||
|
<Badge>{skill.name}</Badge>
|
||||||
|
</Stagger>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user