diff --git a/README.md b/README.md
index e215bc4..18c025b 100644
--- a/README.md
+++ b/README.md
@@ -1,36 +1,67 @@
-This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
+# portfolio
-## Getting Started
+Personal portfolio site. Next.js App Router frontend, PocketBase as the CMS,
+rendered with on-demand ISR — content is served static and revalidated by
+webhook when the CMS changes.
-First, run the development server:
+## Stack
+
+- **Next.js 16** (App Router, React 19, standalone output)
+- **TypeScript** (strict)
+- **Tailwind CSS v4**
+- **PocketBase** — headless CMS, fetched at build/runtime via a typed client
+- **Biome** — lint + format
+- **Vitest** + Testing Library — unit/component tests
+- **Storybook** (a11y + Vitest addons) — component workshop
+- **Yarn 4**
+
+Architecture follows [Feature-Sliced Design](https://feature-sliced.design/)
+adapted to Next.js (`src/{shared,entities,widgets}` + `app/`).
+
+## Rendering
+
+Pages are statically generated and served through ISR. There is no fixed TTL —
+PocketBase calls the `/api/revalidate` webhook after any content mutation,
+which purges the matching cache tag (`revalidateTag`). See
+[`app/api/revalidate/route.ts`](app/api/revalidate/route.ts).
+
+## Getting started
```bash
-npm run dev
-# or
+yarn install
yarn dev
-# or
-pnpm dev
-# or
-bun dev
```
-Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
+Open http://localhost:3000.
-You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
+Without a reachable PocketBase, the app falls back to fixtures in
+[`app/api/collections/[collection]/records/route.ts`](app/api/collections/%5Bcollection%5D/records/route.ts).
-This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
+## Environment
-## Learn More
+```
+PB_URL # internal PocketBase URL (server-side fetches)
+PB_PUBLIC_URL # public PocketBase URL (image optimizer allowlist, build-time)
+REVALIDATE_SECRET # shared secret for the /api/revalidate webhook
+```
-To learn more about Next.js, take a look at the following resources:
+`PB_PUBLIC_URL` must be present at `next build` time — `images.remotePatterns`
+is frozen into the build.
-- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
-- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
+## Scripts
-You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
+| Command | Description |
+|---|---|
+| `yarn dev` | Dev server |
+| `yarn build` | Production build (standalone) |
+| `yarn start` | Serve the build |
+| `yarn test` | Run tests (Vitest) |
+| `yarn check` | Lint + format (Biome, writes fixes) |
+| `yarn check:ci` | Lint + format check (no writes) |
+| `yarn storybook` | Storybook on :6006 |
-## Deploy on Vercel
+## Deployment
-The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
-
-Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
+Built as a standalone Docker image; PocketBase reached over the internal
+Docker network. Content updates trigger the revalidation webhook — no rebuild
+needed.
diff --git a/next.config.ts b/next.config.ts
index a395085..b95431b 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -21,6 +21,9 @@ const nextConfig: NextConfig = {
experimental: {
viewTransition: true,
},
+ turbopack: {
+ root: __dirname,
+ },
};
export default nextConfig;
diff --git a/src/entities/experience/ui/ExperienceCard/ExperienceCard.tsx b/src/entities/experience/ui/ExperienceCard/ExperienceCard.tsx
index 1208d4d..a7fc021 100644
--- a/src/entities/experience/ui/ExperienceCard/ExperienceCard.tsx
+++ b/src/entities/experience/ui/ExperienceCard/ExperienceCard.tsx
@@ -41,7 +41,8 @@ export function ExperienceCard({ title, company, period, description, stack, cla
{period}
{company}
{stack.length > 0 && (
-
+ /* Golden-ratio cap for the stacked (top) tablet layout; full width on mobile, and lg sidebar is already width-constrained. */
+
{stack.map((tech) => (
{tech}
diff --git a/src/entities/project/ui/ProjectCard/ProjectCard.tsx b/src/entities/project/ui/ProjectCard/ProjectCard.tsx
index 6a1cd63..efc5e99 100644
--- a/src/entities/project/ui/ProjectCard/ProjectCard.tsx
+++ b/src/entities/project/ui/ProjectCard/ProjectCard.tsx
@@ -47,7 +47,8 @@ export function ProjectCard({ title, year, description, tags, url, imageUrl, pri
{year}
{tags.length > 0 && (
-
+ /* Golden-ratio cap for the stacked (top) tablet layout; full width on mobile, and lg sidebar is already width-constrained. */
+
{tags.map((tag) => (
{tag}
diff --git a/src/shared/styles/theme.css b/src/shared/styles/theme.css
index 869a1b0..f9fed13 100644
--- a/src/shared/styles/theme.css
+++ b/src/shared/styles/theme.css
@@ -69,17 +69,21 @@
--space-20: 10rem;
/* === BORDERS === */
- --border-width: 3px;
- --radius: 0px;
+ --border-width: 1.5px;
+ --radius: 5px;
- /* === BRUTALIST SHADOWS === */
- --shadow-brutal-xs: 1px 1px 0 var(--blue);
- --shadow-brutal-sm: 3px 3px 0 var(--blue);
- --shadow-brutal: 5px 5px 0 var(--blue);
- --shadow-brutal-md: 7px 7px 0 var(--blue);
- --shadow-brutal-lg: 8px 8px 0 var(--blue);
- --shadow-brutal-xl: 10px 10px 0 var(--blue);
- --shadow-brutal-2xl: 12px 12px 0 var(--blue);
+ /* Muted offset shadow — quieter than the old hard-blue brutalist stamp.
+ * Softer alpha + tighter offsets keep the offset-shadow feel, just lighter. */
+ --shadow-color: rgba(4, 28, 243, 0.25);
+
+ /* === OFFSET SHADOWS === */
+ --shadow-brutal-xs: 1px 1px 0 var(--shadow-color);
+ --shadow-brutal-sm: 2px 2px 0 var(--shadow-color);
+ --shadow-brutal: 3px 3px 0 var(--shadow-color);
+ --shadow-brutal-md: 3px 3px 0 var(--shadow-color);
+ --shadow-brutal-lg: 4px 4px 0 var(--shadow-color);
+ --shadow-brutal-xl: 5px 5px 0 var(--shadow-color);
+ --shadow-brutal-2xl: 6px 6px 0 var(--shadow-color);
/* === GRID === */
--grid-gap: var(--space-3);
@@ -151,7 +155,12 @@
outline-offset: 2px;
}
+ /* Background lives on html (not body) so the grain overlay can sit behind
+ * body's content at a negative z-index and still paint against the cream
+ * canvas — with the background on body, a z-index:-1 body::before would hide
+ * behind body's own background instead of showing through the page gaps. */
html {
+ @apply bg-background;
font-size: var(--font-size);
scroll-behavior: smooth;
/* Reserve scrollbar gutter so locking body scroll (e.g. when a modal
@@ -160,23 +169,24 @@
}
body {
- @apply bg-background text-foreground;
+ @apply text-foreground;
font-family: var(--font-body);
font-weight: var(--font-weight-body);
line-height: var(--line-height-tight);
overflow-x: hidden;
}
- /* Page-wide blue dot grain overlay. z-index 100 puts it above the footer
- * (z-50) so the grain reads as continuous across the entire viewport;
- * pointer-events: none keeps everything clickable through it. */
+ /* Page-wide blue dot grain. z-index -1 drops it behind body's content so
+ * opaque surfaces (cards, badges, footer) cover it and the grain only shows
+ * through the transparent page gaps; pointer-events: none keeps everything
+ * clickable through it. */
body::before {
@apply grain-pattern;
content: "";
position: fixed;
inset: 0;
pointer-events: none;
- z-index: 100;
+ z-index: -1;
}
h1,
@@ -231,9 +241,46 @@
}
}
-/* Button elevation transition — only transform animates; shadow snaps instantly */
-.btn-transition {
- transition: transform 0.13s var(--ease-micro);
+/* Button elevation transition — transform + shadow animate together over 130ms
+ * on the micro easing curve; the spring feel comes from the curve, no keyframes. */
+@utility btn-transition {
+ transition: transform 130ms var(--ease-micro);
+}
+
+/* Offset "shadow" as a static pseudo-block instead of box-shadow. box-shadow
+ * swims because it repaints on the main thread while the button's translate
+ * runs on the compositor — the two desync per frame. Here both the button and
+ * its ::before are transforms, so they stay frame-synced. The ::before counter-
+ * translates to hold a fixed 3px world offset while the button lifts/presses,
+ * so the shadow appears frozen and only the button moves. */
+.btn-shadow {
+ position: relative;
+ z-index: 0;
+ transition: transform 130ms var(--ease-micro);
+}
+.btn-shadow::before {
+ content: "";
+ position: absolute;
+ /* ponytail: inset:0 tracks the padding box, ~1.5px shy of the border box;
+ * imperceptible on a hard offset block. Widen with negative inset if it shows. */
+ inset: 0;
+ z-index: -1;
+ border-radius: inherit;
+ background: var(--shadow-color);
+ transform: translate(3px, 3px);
+ transition: transform 130ms var(--ease-micro);
+}
+.btn-shadow:hover {
+ transform: translate(-2px, -2px);
+}
+.btn-shadow:hover::before {
+ transform: translate(5px, 5px);
+}
+.btn-shadow:active {
+ transform: translate(2px, 2px);
+}
+.btn-shadow:active::before {
+ transform: translate(1px, 1px);
}
/* Brutalist utility classes */
@@ -246,8 +293,16 @@
.brutal-shadow-lg {
box-shadow: var(--shadow-brutal-lg);
}
+/* Golden-ratio (1/φ ≈ 0.618) cap for badge/tag groups so they form a tidy
+ * block instead of spanning the full width. Apply with a breakpoint prefix
+ * (e.g. sm:) — on mobile the group should use the full available width. */
+@utility max-w-golden {
+ max-width: 61.8%;
+}
+
@utility brutal-border {
border: var(--border-width) solid var(--blue);
+ border-radius: var(--radius);
}
@utility brutal-border-top {
border-top: var(--border-width) solid var(--blue);
@@ -266,6 +321,7 @@
* ancestor must not have overflow:hidden or the outline gets clipped. */
@utility brutal-outline {
outline: var(--border-width) solid var(--blue);
+ border-radius: var(--radius);
}
/* Tiled blue dot pattern — applied to body::before (page-wide) and reusable
* on any surface that should share the same paper-grain texture. The SVG
@@ -329,16 +385,28 @@
margin-top: 0;
}
+/* Some CMS output wraps li text in a block
(
…
), which drops
+ * the text below the marker. Keep it inline so marker and text share a line. */
+.rich-text ul li > p {
+ display: inline;
+ margin: 0;
+}
+
.rich-text ul li::before {
- content: "◆";
+ content: "";
display: inline-block;
- width: calc(1.5em / 0.55);
- /* reset inherited text-indent so glyph isn't shifted inside the ::before box */
+ /* 0.5em glyph + 1em gap = 1.5em advance, filling the hanging indent */
+ width: 0.5em;
+ height: 0.5em;
+ margin-right: 1em;
+ /* reset inherited text-indent so the box isn't shifted */
text-indent: 0;
- color: var(--blue);
- font-size: 0.55em;
- /* line-height matches parent so diamond centers within the first line box */
- line-height: calc(var(--line-height-relaxed) / 0.55);
+ background: var(--blue);
+ /* squircle: corner-shape is progressive enhancement, ignored where unsupported */
+ border-radius: 30%;
+ /* biome-ignore lint/correctness/noUnknownProperty: corner-shape is valid CSS, newer than Biome's property list */
+ corner-shape: squircle;
+ vertical-align: middle;
}
/* Cross-section view transition (navigation between sections) */
@@ -415,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
@@ -473,6 +565,9 @@ dialog.lightbox {
box-sizing: content-box;
max-width: calc(100vw - 2rem);
max-height: calc(100vh - 10rem);
+ /* Match the wrapper's brutal-outline radius so image corners sit flush
+ * inside the rounded outline instead of poking past it. */
+ border-radius: var(--radius);
}
/* Disable transition animation for Firefox
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/Badge/ui/Badge.tsx b/src/shared/ui/Badge/ui/Badge.tsx
index 9150fd5..0f79939 100644
--- a/src/shared/ui/Badge/ui/Badge.tsx
+++ b/src/shared/ui/Badge/ui/Badge.tsx
@@ -1,14 +1,10 @@
-import type { ReactNode } from 'react';
+import type { ComponentPropsWithoutRef } from 'react';
import { cn } from '$shared/lib';
export type BadgeVariant = 'default' | 'primary' | 'secondary' | 'outline';
export type BadgeSize = 'xs' | 'sm' | 'md';
-interface Props {
- /**
- * Badge content
- */
- children: ReactNode;
+interface Props extends Pick, 'children' | 'className' | 'style'> {
/**
* Visual variant
* @default 'default'
@@ -19,10 +15,6 @@ interface Props {
* @default 'sm'
*/
size?: BadgeSize;
- /**
- * Additional CSS classes
- */
- className?: string;
}
const VARIANTS: Record = {
@@ -41,9 +33,17 @@ const SIZES: Record = {
/**
* Small label for categorization or status.
*/
-export function Badge({ children, variant = 'default', size = 'sm', className }: Props) {
+export function Badge({ children, variant = 'default', size = 'sm', className, ...rest }: Props) {
return (
-
+
{children}
);
diff --git a/src/shared/ui/Button/ui/Button.tsx b/src/shared/ui/Button/ui/Button.tsx
index c3a8103..d5a71ee 100644
--- a/src/shared/ui/Button/ui/Button.tsx
+++ b/src/shared/ui/Button/ui/Button.tsx
@@ -41,14 +41,12 @@ function isAnchorProps(props: RestButton | RestAnchor): props is RestAnchor {
}
const VARIANTS = {
- primary:
- 'brutal-border bg-blue text-cream shadow-[5px_5px_0_rgba(4,28,243,0.35)] hover:-translate-x-0.5 hover:-translate-y-0.5 hover:shadow-[7px_7px_0_rgba(4,28,243,0.35)] active:translate-x-0.5 active:translate-y-0.5 active:shadow-[1px_1px_0_rgba(4,28,243,0.35)]',
- secondary:
- 'brutal-border bg-blue text-cream shadow-brutal hover:-translate-x-0.5 hover:-translate-y-0.5 hover:shadow-brutal-md active:translate-x-0.5 active:translate-y-0.5 active:shadow-brutal-xs',
+ primary: 'brutal-border bg-blue text-cream btn-shadow',
+ secondary: 'brutal-border bg-blue text-cream btn-shadow',
outline:
'brutal-border border-blue/35 bg-cream text-blue hover:border-blue hover:bg-blue/10 active:bg-blue active:text-cream',
ghost:
- 'brutal-border bg-transparent text-blue hover:-translate-x-0.5 hover:-translate-y-0.5 active:translate-x-0.5 active:translate-y-0.5',
+ 'brutal-border bg-transparent text-blue btn-transition hover:-translate-x-0.5 hover:-translate-y-0.5 active:translate-x-0.5 active:translate-y-0.5',
} as const satisfies Record;
const SIZES = {
@@ -57,9 +55,9 @@ const SIZES = {
lg: 'px-8 py-4 text-lg',
} as const satisfies Record;
-/* box-shadow excluded from transition intentionally — snaps instantly so the
- * eye follows the 130ms button movement, not the shadow change. */
-const BASE = 'cursor-pointer btn-transition uppercase tracking-wider';
+/* Elevation lives per-variant: primary/secondary use btn-shadow (static offset
+ * block, button moves), ghost uses btn-transition + translate. */
+const BASE = 'cursor-pointer uppercase tracking-wider';
/**
* Brutalist button with variants and sizes.
diff --git a/src/shared/ui/Link/ui/Link/Link.test.tsx b/src/shared/ui/Link/ui/Link/Link.test.tsx
index 5fce10a..49db5b4 100644
--- a/src/shared/ui/Link/ui/Link/Link.test.tsx
+++ b/src/shared/ui/Link/ui/Link/Link.test.tsx
@@ -12,6 +12,7 @@ import { Link } from './Link';
const PRIMARY = 'underline underline-offset-2 hover:opacity-60 transition-opacity';
const SECONDARY = 'no-underline opacity-60 hover:opacity-100 sm:brutal-border-bottom transition-opacity';
+const TERTIARY = 'no-underline opacity-100 hover:opacity-60 sm:brutal-border-bottom transition-opacity';
describe('internal link', () => {
it('renders an anchor element', () => {
@@ -92,6 +93,18 @@ describe('variant', () => {
}
});
+ it('tertiary applies reversed-hover classes', () => {
+ render(
+
+ About
+ ,
+ );
+ const link = screen.getByRole('link', { name: 'About' });
+ for (const cls of TERTIARY.split(' ')) {
+ expect(link).toHaveClass(cls);
+ }
+ });
+
it('secondary does not apply underline', () => {
render(
diff --git a/src/shared/ui/Link/ui/Link/Link.tsx b/src/shared/ui/Link/ui/Link/Link.tsx
index b1327fa..2e29983 100644
--- a/src/shared/ui/Link/ui/Link/Link.tsx
+++ b/src/shared/ui/Link/ui/Link/Link.tsx
@@ -2,7 +2,7 @@ import NextLink from 'next/link';
import type { ReactNode } from 'react';
import { cn } from '$shared/lib';
-export type LinkVariant = 'primary' | 'secondary';
+export type LinkVariant = 'primary' | 'secondary' | 'tertiary';
/**
* Props for Link.
@@ -24,6 +24,7 @@ interface Props {
* Visual variant.
* primary — text-decoration underline.
* secondary — border-bottom at sm+, no underline on mobile (for icon+label links).
+ * tertiary — like secondary but reversed: bright by default, dims on hover.
* @default 'primary'
*/
variant?: LinkVariant;
@@ -37,6 +38,7 @@ interface Props {
const VARIANTS = {
primary: 'underline underline-offset-2 hover:opacity-60 transition-opacity',
secondary: 'no-underline opacity-60 hover:opacity-100 sm:brutal-border-bottom transition-opacity',
+ tertiary: 'no-underline opacity-100 hover:opacity-60 sm:brutal-border-bottom transition-opacity',
} as const satisfies Record;
/**
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 (
+