Feature/brutalism overhaul #10
@@ -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
|
||||
|
||||
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 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) => (
|
||||
<ExperienceCard
|
||||
key={exp.id}
|
||||
title={exp.role}
|
||||
company={exp.company}
|
||||
period={formatMonthYearRange(exp.start_date, exp.end_date)}
|
||||
description={exp.description}
|
||||
stack={exp.stack}
|
||||
/>
|
||||
{items.map((exp, i) => (
|
||||
<Stagger key={exp.id} index={i} step={240}>
|
||||
<ExperienceCard
|
||||
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,16 +22,17 @@ export default async function ProjectsSection() {
|
||||
return (
|
||||
<div className="space-y-6 max-w-section">
|
||||
{items.map((project, index) => (
|
||||
<ProjectCard
|
||||
key={project.id}
|
||||
title={project.title}
|
||||
year={project.year}
|
||||
description={project.description}
|
||||
tags={project.stack}
|
||||
url={project.url}
|
||||
imageUrl={project.image ? buildFileUrl(project.collectionId, project.id, project.image) : undefined}
|
||||
priority={index === lcpIndex}
|
||||
/>
|
||||
<Stagger key={project.id} index={index} step={240}>
|
||||
<ProjectCard
|
||||
title={project.title}
|
||||
year={project.year}
|
||||
description={project.description}
|
||||
tags={project.stack}
|
||||
url={project.url}
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user