Freeframe

Documentation

Folio

v1.0.2Updated Oct 17, 2026A bold, dark portfolio for designers who want their work to do the talking.

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).

sh
pnpm install
pnpm dev        # http://localhost:3000

Open 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 metadata

Customising#

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:

css
@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-tight and used by --font-sans.
  • Instrument Serif (regular and italic): the italic accents (.serif-accent, font-serif), exposed as --font-instrument-serif and used by --font-serif.

To swap a font, drop its .woff2 into src/fonts/ and change the localFont() call, or use next/font/google:

tsx
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/:

ts
{
  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.

  • The header is src/components/site-header.tsx. Its links come from NAV in src/data/site.ts; the mobile menu adds Home first and numbers the items (01, 02...). The current page gets aria-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.tsx with a <main id="main"> and a <SiteFooter />, export metadata = pageMetadata(...), and add the route to NAV.

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:

tsx
<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:

tsx
<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:

tsx
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 here

Then 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:

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 metadata built with pageMetadata(title, description) from src/lib/metadata.ts, which sets the title, description, Open Graph (with public/img/og-image.png) and a large Twitter card. The home page uses the defaults in src/app/layout.tsx.

  • Social images need absolute URLs: set your domain when building, and the layout's metadataBase uses it:

    sh
    NEXT_PUBLIC_SITE_URL=https://miraokafor.com pnpm build
  • Add a sitemap and robots file with src/app/sitemap.ts and src/app/robots.ts (both work with the static export).

  • For richer search results, add Person JSON-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 one h1 per page.
  • Visible focus rings (:focus-visible in the accent colour) and a keyboard-friendly mobile menu (focus moves in, Esc closes, focus returns).
  • Filter buttons with aria-pressed in a labelled group and a live region for the results; form errors linked with aria-describedby, announced with aria-live, and aria-invalid on fields.
  • Decorative SVGs and the cursor badge are aria-hidden; images have descriptive alt text.
  • 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 directory out.
  • 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.

Search documentation

Search the guides and every template's documentation