Overview#
Folio is a bold, dark portfolio for product designers, developers and small studios. It is built for Mira Okafor, a fictional independent product designer in Lisbon, and has everything a client wants to see before getting in touch: an oversized-type home page with a staggered project grid, a filterable work page, a full case study, an about page with experience, skills and awards, and a contact page with a project inquiry form that collects budget and timeline up front. This is the Next.js version (Next.js 16 App Router, React 19, TypeScript, Tailwind CSS v4, exported as a static site), version 1.0.0. It matches the HTML version of Folio page for page. Live demo: freeframe.dev/templates/folio.
Quick start#
Requirements: Node.js 24 and pnpm (npm works too).
pnpm install
pnpm dev # http://localhost:3000Open http://localhost:3000. Edits to src/ reload instantly.
| Command | What it does |
|---|---|
pnpm dev |
Development server |
pnpm build |
Static export to out/ |
pnpm start |
Serve out/ locally |
pnpm lint |
ESLint |
pnpm typecheck |
Generate route types and run tsc --noEmit |
pnpm build:demo |
Build for the freeframe.dev demo (base path /demos/nextjs/folio) into demo/ |
Pages#
| Route | File | Purpose and notable sections |
|---|---|---|
/ |
src/app/page.tsx |
Home: oversized headline with a live Lisbon clock, staggered "Selected work" grid, services list, client logo marquee, large testimonial |
/work |
src/app/work/page.tsx |
All projects with the category filter (/work#brand links straight to a category) |
/project |
src/app/project/page.tsx |
Case study (Tidal Banking): facts, overview, challenge, process, screens, results, next project |
/about |
src/app/about/page.tsx |
Portrait and story, "In numbers", experience timeline, skills and tools, awards and press |
/contact |
src/app/contact/page.tsx |
Email with a copy button, availability, studio clock, FAQ disclosures, project inquiry form |
| 404 | src/app/not-found.tsx |
"Lost?" page with links home and to the work (exported as 404.html) |
Every page except contact and 404 ends with the footer's big "Let's make it real." call to action.
Project structure#
folio/
src/
app/
layout.tsx root layout: fonts, metadata defaults, header, cursor badge, back-to-top
globals.css Tailwind entry: @theme tokens and component classes
icon.svg favicon
page.tsx home
work/page.tsx work (filterable grid)
project/page.tsx case study
about/page.tsx about
contact/page.tsx contact
not-found.tsx 404
components/
site-header.tsx fixed header, desktop nav and full-screen mobile menu (client)
site-footer.tsx footer with optional call to action
project-card.tsx a project in a grid
work-filter.tsx category filter for the work page (client)
inquiry-form.tsx contact form with validation and thank-you message (client)
copy-button.tsx copy-email button (client)
local-time.tsx HH:MM clock in the studio's time zone (client)
year.tsx current year in the footer (client)
reveal-observer.tsx scroll reveals (client)
cursor-follower.tsx "View" badge that follows the mouse (client)
back-to-top.tsx floating back-to-top button (client)
status-dot.tsx pulsing availability dot
icons.tsx inline SVG icons
data/
site.ts name, email, phone, address, time zone, navigation, social links
projects.ts projects and the work filter categories
home.ts services, client names, testimonial
about.ts numbers, experience, skills, awards
case-study.ts facts, process, screens and results of the case study
lib/
asset.ts prefixes files in public/ with the base path
metadata.ts pageMetadata(): title, description and Open Graph per page
reveal.ts delay(): reveal delay style (--d)
fonts/ Inter Tight and Instrument Serif (woff2) and their licences
public/img/ project art, portrait, case-study screens, og-image.png and its og-image.svg source
next.config.ts static export and base path
template.json gallery metadataCustomising#
Brand and colours#
All colours and fonts are Tailwind theme tokens in the @theme block at the top of src/app/globals.css:
| Token | Default | Used for |
|---|---|---|
--color-bg |
#0b0b0c |
Page background, text on accent buttons |
--color-surface |
#141416 |
Panels (challenge, skills, process cards, project art background) |
--color-raised |
#1c1c1f |
Back-to-top button |
--color-line |
#27272b |
Borders and dividers |
--color-ink |
#f3f0e9 |
Main text |
--color-muted |
#a29e96 |
Secondary text |
--color-accent |
#ff5b2e |
Accent: buttons, serif highlights, cursor badge, focus rings |
--color-accent-soft |
#ff8a63 |
Softer accent (text-accent-soft, bg-accent-soft) |
Each token becomes utilities such as bg-accent, text-muted and border-line. To change the brand colour:
@theme {
--color-accent: #7c5cff;
}Also update themeColor in src/app/layout.tsx, the colours in src/app/icon.svg and the testimonial avatar
gradient (to-[#7a1d00] in src/app/page.tsx). The site is dark only (colorScheme: "dark" in the layout's
viewport); it has no light theme or theme switch.
Typography#
Two fonts, self-hosted with next/font/local in src/app/layout.tsx (the same Latin files Google Fonts serves, so
text sets exactly like the HTML version; licences in src/fonts/*-OFL.txt):
- Inter Tight (variable, weights 300 to 800): everything, exposed as
--font-inter-tightand used by--font-sans. - Instrument Serif (regular and italic): the italic accents (
.serif-accent,font-serif), exposed as--font-instrument-serifand used by--font-serif.
To swap a font, drop its .woff2 into src/fonts/ and change the localFont() call, or use next/font/google:
import { Manrope } from "next/font/google";
const interTight = Manrope({ subsets: ["latin"], variable: "--font-inter-tight" });The headlines use tight negative letter spacing (tracking-[-0.055em]); loosen it for wider fonts.
Content#
Text that appears once lives in the page files (src/app/**/page.tsx); repeated content is in typed arrays in
src/data/:
| File | Exports |
|---|---|
site.ts |
SITE (name, siteName, email, phone, address, city, timeZone, availability), NAV, SOCIAL |
projects.ts |
PROJECTS, FILTERS, filterCount(), types Project and Category |
home.ts |
SERVICES, CLIENTS, TESTIMONIAL |
about.ts |
STATS, EXPERIENCE, SKILLS, AWARDS |
case-study.ts |
CASE_FACTS, CASE_PROCESS, CASE_SCREENS, CASE_RESULTS |
To add a project, add an entry to PROJECTS (newest first) and put its art in public/img/:
{
title: "Lumen Notes",
type: "Mobile app",
year: 2026,
summary: "A writing app that remembers where you left off.",
categories: ["product"],
image: "/img/project-lumen.svg",
alt: "Lumen Notes — mobile app design shown on screens",
href: "/project",
featured: true,
},featured projects appear in the home page grid (every second one is pushed down for the staggered layout, and the
count in "Selected work (04)" follows). The Work page shows every project, and the filter counts update themselves.
To add a category, extend the Category type and add it to FILTERS.
All cards link to the single example case study, /project. For a case study per project, move the page to
src/app/work/[slug]/page.tsx, add a slug to each project, export generateStaticParams() returning the slugs,
and point href at /work/<slug>. Keep the case-study content in a data file keyed by slug, like
src/data/case-study.ts.
The email address in SITE.email is used by the header's mobile menu, the footer, the contact page and its copy
button. The studio clock reads SITE.timeZone (an IANA name such as Europe/Lisbon).
Rolling dates. "Booking projects from November 2026" on the contact page is <MonthsAhead> (src/components/months-ahead.tsx) shows the month n months from now and its year: the
static build shows its fallback and the browser the rolling date, so the line never goes stale. For a fixed date,
write it as plain text instead.
Images and artwork#
All artwork is original SVG in public/img/:
| File | Used on | Size to keep |
|---|---|---|
project-*.svg |
Project cards, case-study hero, next-project link | 16:10 (1600 × 1000) |
tidal-screens.svg, tidal-system.svg |
Case-study screens | 16:9 (1600 × 900) |
portrait.svg |
About | 4:5 (800 × 1000) |
og-image.png |
Social sharing | 1200 × 630 (og-image.svg is its source) |
To use photos, save JPG or WebP files at those ratios in public/img/ and change the paths in src/data/ or the page.
Images are plain <img> tags wrapped in asset() so they work under a base path; containers crop with
object-cover and fixed aspect-[16/10]/aspect-[16/9] ratios. Write useful alt text, or alt="" for purely
decorative images (like the next-project thumbnail, whose link already names the project).
The favicon is src/app/icon.svg (Next.js adds the <link> itself). Replace public/img/og-image.png with your own
1200 × 630 image; edit og-image.svg and export it to PNG if you like.
Navigation, header and footer#
- The header is
src/components/site-header.tsx. Its links come fromNAVinsrc/data/site.ts; the mobile menu adds Home first and numbers the items (01, 02...). The current page getsaria-current="page". - The footer is
src/components/site-footer.tsx. Because the contact and 404 pages leave out its call to action, each page renders<SiteFooter />(or<SiteFooter cta={false} />) after its own<main id="main">, instead of the layout doing it. - To add a page, create
src/app/<route>/page.tsxwith a<main id="main">and a<SiteFooter />, exportmetadata = pageMetadata(...), and add the route toNAV.
Features#
Headline and scroll reveals#
Headlines reveal line by line: each line is a .reveal-line wrapping a <span> that slides up. Other blocks with
.reveal fade and rise into view. src/components/reveal-observer.tsx adds .is-visible with an
IntersectionObserver (and again after every client-side navigation); the styles are in globals.css. Stagger an
element with the delay() helper:
<span className="reveal-line"><span style={delay(120)}>interfaces for</span></span>Content is only hidden while JavaScript runs (the layout adds a js class to <html> before the first paint). With
reduced motion, or without IntersectionObserver, everything is shown at once.
Work filter#
src/components/work-filter.tsx renders the filter buttons (data-filter, aria-pressed), the "06 projects" count
(data-filter-count) and the grid. Clicking a filter shows the cards whose data-category contains the value: the
others fade out (.is-hiding) and are then hidden; with reduced motion they are hidden at once. The filter is kept in
the URL hash, so /work#brand opens on Brand (also when the hash changes on the page) and "All" clears the hash. Hashes that aren't a filter (such as
#nothing or #constructor) are ignored. The grid is an aria-live="polite" region and the buttons are a labelled
group, so screen readers hear the change.
Cursor badge#
On devices with a mouse or trackpad, and when motion is allowed, src/components/cursor-follower.tsx shows a round
accent badge that follows the pointer and grows over any element with a data-cursor attribute, showing its text:
"View" on project cards, "Next" on the next-project link, "Say hi" on the footer call to action. Add data-cursor="..."
to any link to use it. The badge is decorative (aria-hidden) and ignores pointer events. To remove it, delete
<CursorFollower /> from the layout.
Mobile menu#
Below the md breakpoint the header shows a two-line menu button (.menu-toggle, aria-expanded,
aria-controls="mobile-menu", label "Open menu"/"Close menu"). The full-screen #mobile-menu fades in, locks page
scrolling and focuses its first link. Esc closes it and returns focus to the button; it also closes when a link in it is
followed or the window grows to desktop width.
Studio clock and year#
src/components/local-time.tsx renders <span data-local-time> with the current time in SITE.timeZone (24-hour
HH:MM), updated every 30 seconds. It appears in the home hero, the footer and the contact page. The static HTML
contains 12:00 until JavaScript runs. src/components/year.tsx keeps the footer's copyright year current.
Copy email#
src/components/copy-button.tsx copies its value to the clipboard and changes its label to "Copied" for 1.8
seconds ("Press Ctrl+C" if the clipboard isn't available). Use it for anything else worth copying:
<CopyButton value="+351 900 000 000" label="Copy phone" />Client marquee#
The "Trusted by teams at" list scrolls endlessly (.marquee-track, --animate-marquee in @theme, 38 seconds per
loop) and pauses on hover. The list in CLIENTS is rendered twice so the loop is seamless; each name has its own
type treatment in className. With reduced motion the animation stops.
FAQ disclosures#
The contact page's "Good to know" questions are native <details>/<summary> elements (keyboard and screen-reader
friendly without JavaScript). Edit FAQ at the top of src/app/contact/page.tsx.
Back to top#
src/components/back-to-top.tsx shows a floating button after you scroll about one screen. It steps aside while the
footer's own "Back to top" link is on screen, scrolls to the top (instantly with reduced motion) and moves focus to
<main>. Mark your own fixed UI with data-back-to-top-avoid (the button lifts above it) or data-back-to-top-hide
(it hides while overlapping). To remove it, delete <BackToTop /> from the layout and the .back-to-top styles.
Forms#
The project inquiry form (form[data-validate]) is src/components/inquiry-form.tsx, used on the contact page.
| Field | Rule | Message |
|---|---|---|
Name (name) |
At least 2 characters | Please tell me your name. |
Email (email) |
Looks like name@domain.tld |
That email address doesn't look right. |
Budget (budget) |
A range chosen | Pick a budget range so I can plan. |
Ideal start (timeline) |
An option chosen | When would you like to start? |
Project details (message) |
At least 20 characters | A couple of sentences about the project, please (20+ characters). |
"I'm interested in" checkboxes (type) and Company are optional. The rules are in RULES at the top of the file.
A field is checked when it loses focus (if filled in or changed), again as you type once it's marked invalid, and
every field on submit; errors are written to the <p id="<field>-error"> linked with aria-describedby, the field gets
aria-invalid, and focus moves to the first invalid field. A valid submission hides the form and shows
#form-success ("Thank you, Ana.") with focus on it.
The demo sends nothing. To receive inquiries with a form service such as Formspree, make
onSubmit async and post the form data where the "Demo only" comment is:
const res = await fetch("https://formspree.io/f/your-id", {
method: "POST",
body: new FormData(form),
headers: { Accept: "application/json" },
});
if (!res.ok) return; // show an error message hereThen keep the existing success code, and update the thank-you text, which says the form is a demo.
Without the static export (see below) you can post to your own Route Handler instead, for example
src/app/api/inquiry/route.ts:
export async function POST(request: Request) {
const data = await request.formData();
// send an email, save to a database...
return Response.json({ ok: true });
}and fetch("/api/inquiry", { method: "POST", body: new FormData(form) }), or turn the submit into a Server Action.
SEO and sharing#
-
Each page exports
metadatabuilt withpageMetadata(title, description)fromsrc/lib/metadata.ts, which sets the title, description, Open Graph (withpublic/img/og-image.png) and a large Twitter card. The home page uses the defaults insrc/app/layout.tsx. -
Social images need absolute URLs: set your domain when building, and the layout's
metadataBaseuses it:shNEXT_PUBLIC_SITE_URL=https://miraokafor.com pnpm build -
Add a sitemap and robots file with
src/app/sitemap.tsandsrc/app/robots.ts(both work with the static export). -
For richer search results, add
PersonJSON-LD to the about page in a<script type="application/ld+json">.
Accessibility#
Built in:
- A "Skip to content" link to
<main id="main">, landmarks (header, labelled navigations, main, footer) and oneh1per page. - Visible focus rings (
:focus-visiblein the accent colour) and a keyboard-friendly mobile menu (focus moves in, Esc closes, focus returns). - Filter buttons with
aria-pressedin a labelled group and a live region for the results; form errors linked witharia-describedby, announced witharia-live, andaria-invalidon fields. - Decorative SVGs and the cursor badge are
aria-hidden; images have descriptivealttext. - Reduced motion: no reveals, marquee, cursor badge or smooth scrolling, and the filter hides cards without fading.
When editing, keep one h1 per page, give new inputs a <label>, keep contrast on the dark background (muted text
#a29e96 is the darkest grey to use for body text), and give any new data-cursor link a real text label.
Building and deploying#
pnpm build writes a fully static site to out/ (output: "export" in next.config.ts). Deploy it anywhere:
- Vercel: import the repository; no settings needed.
- Netlify / Cloudflare Pages: build command
pnpm build, publish directoryout. - Any static host: upload the contents of
out/.
To serve the site from a sub-folder, build with a base path: DEMO_BASE_PATH=/portfolio pnpm build.
next.config.ts passes it to basePath and to NEXT_PUBLIC_BASE_PATH, which asset() uses for images.
pnpm build:demo does this for the freeframe.dev demo and writes demo/, which you can delete.
Need server features (a Route Handler for the form, Server Actions, middleware)? Remove output: "export" and the
images.unoptimized setting from next.config.ts and deploy to a Node-capable host such as Vercel. Everything else
keeps working.
TypeScript setup#
package.json installs @typescript/native (TypeScript 7, which provides tsc for pnpm typecheck) and
typescript as an alias of @typescript/typescript6, because typescript-eslint and parts of the Next.js toolchain
still need the TypeScript 6 compiler API. Once typescript-eslint supports TypeScript 7, replace both with a single
typescript@7. eslint.config.mjs also pins settings.react.version, because eslint-plugin-react can't detect it
under ESLint 10.
Troubleshooting#
| Problem | Fix |
|---|---|
| Images are missing when the site lives in a sub-folder | Build with DEMO_BASE_PATH=/your-folder and wrap new image paths in asset(). |
| Social previews show no image | Set NEXT_PUBLIC_SITE_URL to your live domain when building, so og:image is an absolute URL. |
| Content stays invisible | Elements with reveal need RevealObserver in the layout; check the browser console for errors that stop it. |
/work#something shows all projects |
Only values in FILTERS are applied; add the category there and to the projects' categories. |
| The clock shows the wrong city's time | Change SITE.timeZone in src/data/site.ts to an IANA time zone, and SITE.city. |
| The inquiry form "succeeds" but no email arrives | The demo sends nothing; connect it as described in Forms. |
pnpm lint crashes detecting React |
Keep settings.react.version in eslint.config.mjs. |
| A new utility class has no effect | Tailwind finds classes by scanning the source; write class names in full (not built from pieces of strings) in a .tsx or .ts file. |
License#
MIT: the full text is in LICENSE.txt, included in the download. You can use, change and sell sites built with this
template, including the SVG artwork. The fonts are under the SIL Open Font License (src/fonts/*-OFL.txt). Mira
Okafor, her clients and their projects are fictional.