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).
pnpm install # also runs `nuxt prepare` (types and the ESLint config in .nuxt/)
pnpm dev # http://localhost:3000Open 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 metadataCustomizing#
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:
@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:
@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/:
{
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.
Navigation, header and footer#
- The header is
app/components/SiteHeader.vue. Its links come fromNAVinapp/data/site.ts; the mobile menu usesMOBILE_NAV(Home first) and numbers the items (01, 02...). The current page getsaria-current="page"throughuseCurrentPage(); links that must never be marked (the logo, "Start a project") pass:aria-current="undefined", becauseNuxtLinkmarks any link to the current page on its own. - The footer is
app/components/SiteFooter.vue, rendered byapp/layouts/default.vue. A page that leaves out the big call to action setsdefinePageMeta({ footerCta: false })(seeapp/pages/contact.vue); the 404 page passes:footer-cta="false"to<NuxtLayout>. - To add a page, create
app/pages/<route>.vuewith a<main id="main">as its root and ausePageSeo()call, add the route toroutesinnuxt.config.tsand 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. 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):
<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:
<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:
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 hereWith 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:
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 (withimg/og-image.png) and a large Twitter card. The 404 page passesnoindex: true. -
Canonical and social URLs are absolute: set your domain when building (the default is
https://folio.example.com):shSITE_URL=https://miraokafor.example pnpm generate -
Add a
sitemap.xmlandrobots.txttopublic/(or use a sitemap module) before launch. -
For richer search results, add
PersonJSON-LD to the about page withuseHead({ script: [...] }).
Accessibility#
Built in:
- A "Skip to content" link to
<main id="main">, landmarks (header, labeled navigations, main, footer) and oneh1per page. - Visible focus rings (
:focus-visiblein the accent color) and a keyboard-friendly mobile menu (focus moves in, Esc closes, focus returns). - Filter buttons with
aria-pressedin a labeled 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 gray to use for body text), and give any new data-cursor link a real text label.
Building and deploying#
pnpm generate # static site in .output/public/
pnpm preview # serve it locallynuxt 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:
DEMO_BASE_PATH=/portfolio pnpm generatepnpm 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.