A personal portfolio site built with React, Vite, and anime.js. It showcases projects as Markdown files with a motion system that respects the user's prefers-reduced-motion setting.
| Layer | Technology |
|---|---|
| Framework | React 18 + React Router v6 |
| Build | Vite 5 |
| Animation | anime.js 3 |
| Content | Markdown files parsed with gray-matter + react-markdown |
| Tests | Vitest + Testing Library + fast-check (property tests) |
| Deployment | Netlify |
src/
routes/ # Page components (Home, Projects, ProjectDetail, About, NotFound)
components/ # Shared UI (Nav, Footer, ProjectCard, Reveal, MotionLayout, ParallaxLayer)
content/
projects/ # One .md file per project
loader.js # Parses and validates all project Markdown at build time
schema.js # Required frontmatter fields
motion/
engine.js # Animation primitives (playHero, playReveal, applyParallax, …)
tokens.js # Duration, easing, and stagger constants
useReducedMotion.js # Hook that tracks prefers-reduced-motion live
styles/
index.css # Global styles
Prerequisites: Node.js 18+
# Install dependencies
npm install
# Start the dev server
npm run devThen open http://localhost:5173.
Create a Markdown file under src/content/projects/. The filename becomes the URL slug — my-project.md maps to /projects/my-project.
The file must include these frontmatter fields:
---
title: "My Project"
tech: ["TypeScript", "React"]
links:
- { label: "Repo", url: "https://github.com/you/my-project" }
description: "One sentence description shown on the project card."
---
Full Markdown body goes here.| Field | Type | Description |
|---|---|---|
title |
string | Displayed as the page heading and card title |
tech |
string array | Rendered as chips on the card and detail page |
links |
array of { label, url } |
External links on the detail page |
description |
string | Summary shown on the projects listing |
If any required field is missing, npm run build will fail with a message naming the file and every missing field.
npm run dev # Development server with HMR
npm run build # Production build → dist/
npm run preview # Serve the production build locally
npm test # Run the property test suite once
npm run test:watch # Run tests in watch modeThe animation engine lives in src/motion/engine.js and exposes five primitives:
playHero— staggered character entrance on the Home page headingplayReveal— fade/slide-up triggered when an element scrolls into viewapplyParallax— writes atranslate3doffset driven byscrollYplayTransition— cross-fade between outgoing and incoming route containersplayMicro— scale pulse on project card hover/focus
Every primitive accepts { reducedMotion: boolean }. When true, it skips the timeline, sets the target to its final visible state (opacity: 1, no transform), and returns a resolved promise — so the rest of the app never needs to branch on the preference.
The site deploys to Netlify automatically. The netlify.toml at the repo root sets:
[build]
command = "npm run build"
publish = "dist"
[[redirects]]
from = "/*"
to = "/index.html"
status = 200The redirect rule ensures React Router's client-side routes work correctly on direct navigation and page refresh.
The test suite uses fast-check property tests rather than example-based unit tests. Each test file sits next to the code it verifies:
| File | Property tested |
|---|---|
motion/engine.property.test.js |
Reduced-motion gate, parallax determinism |
motion/useReducedMotion.property.test.jsx |
Live preference toggle |
content/loader.*.property.test.js |
Slug derivation, record shape, missing-field errors |
routes/Projects.property.test.jsx |
Listing renders one card per record with correct links |
routes/ProjectDetail.property.test.jsx |
Detail page renders all content; unknown slug shows NotFound |
App.property.test.jsx |
Unknown route shows site NotFound; nav persists on every route |
components/ProjectCard.property.test.jsx |
Micro-interaction fires exactly once per activation |