Freeframe

Documentation

Folio

v1.0.1Updated Nov 11, 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 Lancaster, PA, 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 Nuxt version (Nuxt 4, Vue 3, TypeScript, Tailwind CSS v4), version 1.0.1, and it generates to static files. It matches the HTML version of Folio page for page. The template page, with a link to the live demo, is at freeframe.dev/templates/folio.

The site is a front end only: the inquiry form doesn't send anything until you connect it (see Forms).

Quick start#

Requirements: Node.js 24 or newer and pnpm (npm works too).

sh
pnpm install      # also runs `nuxt prepare` (types and the ESLint config in .nuxt/)
pnpm dev          # http://localhost:3000

Open http://localhost:3000. Edits to app/ reload instantly.

Script What it does
pnpm dev Development server
pnpm generate Static site in .output/public/ (nuxt generate): every page prerendered to HTML
pnpm build A Node.js server build in .output/ (nuxt build), for hosts that run Nuxt as a server
pnpm preview Serve the last build locally
pnpm lint ESLint (@nuxt/eslint)
pnpm typecheck nuxt prepare and vue-tsc
pnpm build:demo Generate for freeframe.dev under /demos/nuxt/folio into demo/

Pages#

Route File Purpose and notable sections
/ app/pages/index.vue Home: oversized headline with a live Lancaster clock, staggered "Selected work" grid, services list, client logo marquee, large testimonial
/work app/pages/work.vue All projects with the category filter (/work#brand links straight to a category)
/project app/pages/project.vue Case study (Tidal Banking): facts, overview, challenge, process, screens, results, next project
/about app/pages/about.vue Portrait and story, "In numbers", experience timeline, skills and tools, awards and press
/contact app/pages/contact.vue Email with a copy button, availability, studio clock, FAQ disclosures, project inquiry form
404 app/error.vue "Lost?" page with links home and to the work (generated 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/
  app/
    app.vue                 <NuxtLayout><NuxtPage /></NuxtLayout>
    error.vue               404 page
    router.options.ts       query strings written with encodeURIComponent
    layouts/default.vue     skip link, header, page, footer, cursor badge, back-to-top button
    pages/                  index, work, project, about, contact (one file per page)
    components/
      SiteHeader.vue        fixed header, desktop nav and full-screen mobile menu
      SiteFooter.vue        footer with optional call to action and the current year
      ProjectCard.vue       a project in a grid
      WorkFilter.vue        category filter for the work page
      InquiryForm.vue       contact form with validation and thank-you message
      CopyButton.vue        copy-email button
      LocalTime.vue         clock in the studio's time zone
      MonthsAhead.vue       a month that rolls forward from today
      CursorFollower.vue    "View" badge that follows the mouse
      BackToTop.vue         floating back-to-top button
      ArrowIcon.vue         the arrow inside buttons
    composables/
      usePageSeo.ts         title, description, canonical and Open Graph per page
      useCurrentPage.ts     aria-current for menu links
      useAsset.ts           prefixes files in public/ with the base path
    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
      contact.ts            form choices and the FAQ
    plugins/
      reveal.client.ts      scroll reveals
      stable-build.server.ts  reproducible builds (drops the build timestamp)
    utils/                  delay() for reveal delays, pad2() for 01, 02...
    assets/css/main.css     Tailwind entry: fonts, @theme tokens and component classes
    assets/fonts/           Inter Tight and Instrument Serif (woff2) and their licenses
  public/img/               favicon, project art, portrait, case-study screens, og-image.png and its og-image.svg source
  nuxt.config.ts            base path, head defaults, SITE_URL, prerendered routes, Tailwind
  template.json             gallery metadata

Customizing#

Brand and colors#

All colors and fonts are Tailwind theme tokens in the @theme block of app/assets/css/main.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 color:

css
@theme {
  --color-accent: #7c5cff;
}

Also update theme-color in nuxt.config.ts (app.head.meta), the colors in public/img/favicon.svg and the testimonial avatar gradient (to-[#7a1d00] in app/pages/index.vue). The site is dark only (color-scheme: dark in nuxt.config.ts); it has no light theme or theme switch.

Typography#

Two fonts, self-hosted from app/assets/fonts/ with @font-face rules at the top of main.css (the same Latin files Google Fonts serves, so text sets exactly like the HTML version; licenses in app/assets/fonts/*-OFL.txt):

  • Inter Tight (variable, weights 300 to 800): everything, through --font-sans.
  • Instrument Serif (regular and italic): the italic accents (.serif-accent, font-serif), through --font-serif.

To swap a font, put its .woff2 in app/assets/fonts/, change the @font-face rule and the family name in --font-sans or --font-serif:

css
@font-face {
  font-family: "Manrope";
  font-style: normal;
  font-weight: 200 800;
  font-display: swap;
  src: url("../fonts/Manrope-latin.woff2") format("woff2");
}

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 (app/pages/*.vue); repeated content is in typed arrays in app/data/:

File Exports
site.ts SITE (name, siteName, email, phone, address, city, timeZone, availability), NAV, MOBILE_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
contact.ts PROJECT_TYPES, BUDGETS, TIMELINES, FAQS

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 app/pages/work/[slug].vue, add a slug to each project, list each /work/<slug> in routes in nuxt.config.ts, and point href at /work/<slug>. Keep the case-study content in a data file keyed by slug, like app/data/case-study.ts, and read it with useRoute().params.slug.

The email address in SITE.email is used by the mobile menu, the footer, the contact page and its copy button. The studio clock reads SITE.timeZone (an IANA name such as America/New_York).

Rolling dates. "Booking projects from November 2026" on the contact page is <MonthsAhead :n="1" fallback="November 2026" /> (app/components/MonthsAhead.vue): the month n months from now and its year. The generated HTML shows fallback, and the browser replaces it with the rolling month after the page loads, so the line never goes stale. format="short" gives "Dec 2026" and :day="15" gives "December 15, 2026". 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)
favicon.svg Browser tab square

To use photos, save JPG or WebP files at those ratios in public/img/ and change the paths in app/data/ or the page. Paths are written without a leading slash (img/portrait.svg) and passed through useAsset(), 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).

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 app/components/SiteHeader.vue. Its links come from NAV in app/data/site.ts; the mobile menu uses MOBILE_NAV (Home first) and numbers the items (01, 02...). The current page gets aria-current="page" through useCurrentPage(); links that must never be marked (the logo, "Start a project") pass :aria-current="undefined", because NuxtLink marks any link to the current page on its own.
  • The footer is app/components/SiteFooter.vue, rendered by app/layouts/default.vue. A page that leaves out the big call to action sets definePageMeta({ footerCta: false }) (see app/pages/contact.vue); the 404 page passes :footer-cta="false" to <NuxtLayout>.
  • To add a page, create app/pages/<route>.vue with a <main id="main"> as its root and a usePageSeo() call, add the route to routes in nuxt.config.ts and 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. app/plugins/reveal.client.ts adds .is-visible with an IntersectionObserver once the app has mounted, and a MutationObserver picks up the next page after a link click; the styles are in main.css. Stagger an element with the delay() helper (app/utils/delay.ts):

html
<span class="reveal-line"><span :style="delay(120)">interfaces for</span></span>

Content is only hidden while JavaScript runs (a script in nuxt.config.ts adds a js class to <html> before the first paint). With reduced motion, or without IntersectionObserver, everything is shown at once. The plugin adds the class with classList, so don't put .reveal on an element that also has a :class binding: Vue would drop is-visible the next time it updates the element.

Work filter#

app/components/WorkFilter.vue renders the filter buttons (data-filter, aria-pressed), the "06 projects" count (data-filter-count) and the grid, whose cards are <ProjectCard filterable> passed in its slot. 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 labeled group, so screen readers hear the change.

Cursor badge#

On devices with a mouse or trackpad, and when motion is allowed, app/components/CursorFollower.vue 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#

app/components/LocalTime.vue renders <span data-local-time> with the current time in SITE.timeZone (12-hour, such as 3:00 PM), updated every 30 seconds. It appears in the home hero, the footer and the contact page. The generated HTML contains 12:00 PM until the page has loaded. The footer's copyright year starts as the year written in SiteFooter.vue and is set from the visitor's clock after loading, so it never goes stale.

Copy email#

app/components/CopyButton.vue 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:

html
<CopyButton value="(717) 555-0142" 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 FAQS in app/data/contact.ts.

Back to top#

app/components/BackToTop.vue 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 app/components/InquiryForm.vue, 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 choices are in app/data/contact.ts. The rules are in RULES at the top of the component. A field is checked when it loses focus with something in it and when its value is committed (the change event), 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, and a short reference (MO- and four characters, made in the browser by makeRef(); use your form service's own reference instead if it returns one). The message promises a reply within two business days, like the "Response time" on the page: change both if yours differs.

The demo sends nothing. To receive inquiries with a form service such as Formspree, post the form data in onSubmit() where the "Demo only" comment is, before the success message is shown:

ts
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

With pnpm build (a Node.js server instead of static files) you can post to your own server route instead, for example server/api/inquiry.post.ts:

ts
export default defineEventHandler(async (event) => {
  const data = await readFormData(event);
  // send an email, save to a database...
  return { ok: true };
});

and fetch("/api/inquiry", { method: "POST", body: new FormData(form) }).

SEO and sharing#

  • Each page calls usePageSeo({ title, description }) (app/composables/usePageSeo.ts), which sets the title, description, a canonical link, Open Graph tags (with img/og-image.png) and a large Twitter card. The 404 page passes noindex: true.

  • Canonical and social URLs are absolute: set your domain when building (the default is https://folio.example.com):

    sh
    SITE_URL=https://miraokafor.example pnpm generate
  • Add a sitemap.xml and robots.txt to public/ (or use a sitemap module) before launch.

  • For richer search results, add Person JSON-LD to the about page with useHead({ script: [...] }).

Accessibility#

Built in:

  • A "Skip to content" link to <main id="main">, landmarks (header, labeled navigations, main, footer) and one h1 per page.
  • Visible focus rings (:focus-visible in the accent color) and a keyboard-friendly mobile menu (focus moves in, Esc closes, focus returns).
  • Filter buttons with aria-pressed in a labeled 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 gray to use for body text), and give any new data-cursor link a real text label.

Building and deploying#

sh
pnpm generate     # static site in .output/public/
pnpm preview      # serve it locally

nuxt generate prerenders every route listed in routes in nuxt.config.ts as <page>.html (work.html, linked as /work), plus 404.html from app/error.vue. Next to each page is a folder with its _payload.json, which Nuxt loads when you move between pages. Upload .output/public/ to any static host (Netlify, Cloudflare Pages, GitHub Pages, S3, Vercel). Hosts that don't map /work to work.html on their own need that rewrite (Vercel: "cleanUrls": true); most serve 404.html for unknown URLs.

To serve the site from a sub-folder, set DEMO_BASE_PATH, which becomes Nuxt's app.baseURL:

sh
DEMO_BASE_PATH=/portfolio pnpm generate

pnpm build makes a Node.js server build instead (run it with node .output/server/index.mjs); the pages are still prerendered, and you can add server routes. demo/ is the freeframe.dev demo build and can be deleted.

TypeScript setup#

pnpm typecheck runs vue-tsc against the configs Nuxt writes to .nuxt/ (tsconfig.json references them). typescript is aliased to @typescript/typescript6, the TypeScript 6 API that vue-tsc and typescript-eslint load; replace it with typescript@7 once they support it.

Troubleshooting#

Problem Fix
Images are missing when the site lives in a sub-folder Build with DEMO_BASE_PATH=/your-folder and pass new image paths through useAsset(), without a leading slash.
Social previews show no image Set SITE_URL to your live domain when building, so og:image is an absolute URL.
Content stays invisible Elements with reveal are shown by app/plugins/reveal.client.ts; check the browser console for errors that stop it.
A revealed block disappears after an update It has .reveal and a :class binding; move one of them to a wrapper element.
/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 app/data/site.ts to an IANA time zone, and SITE.city.
A new page is missing from the generated site Add its route to routes in nuxt.config.ts.
pnpm lint can't find .nuxt/eslint.config.mjs Run pnpm install or npx nuxt prepare first.
The form shows its thank-you message but nothing arrives It sends nothing until you connect it; see Forms.

License#

Freeframe Commercial License: use it in unlimited websites and apps for yourself and your clients; it may not be resold or redistributed as a template. The full terms, with your licensee details, are in LICENSE.txt and at https://freeframe.dev/license#commercial. Third-party components keep their own licenses. Inter Tight and Instrument Serif are under the SIL Open Font License (app/assets/fonts/*-OFL.txt). Mira Okafor, her clients and their projects are fictional.

Search documentation

Search the guides and every template's documentation