Overview#
Folio is a dark, type-led portfolio for product designers, developers and small studios. The demo content belongs to Mira Okafor, a fictional independent product designer in Lisbon, with six sample projects and one full case study. It is built with plain HTML5, Tailwind CSS v4 and a small vanilla JavaScript file, so it runs without a build step. This guide covers version 1.1.4.
Live demo: freeframe.dev/templates/folio
Quick start#
-
Unzip the download and open
index.htmlin a browser. Every page and link works straight from your computer. -
For a setup closer to real hosting, serve the folder with any static server:
shcd folio python3 -m http.server 8000 # then open http://localhost:8000
You only need Node.js 24 if you change src/input.css or add Tailwind classes that aren't used yet
(see Building and deploying).
Pages#
| File | Purpose | Notable sections |
|---|---|---|
index.html |
Home | Oversized animated headline, local-time status line, selected work, services, client marquee, testimonial, call to action |
work.html |
All projects | Sticky category filter (All / Product / Web / Brand) with a live project count |
project.html |
Case study (Tidal Banking) | Facts, overview, challenge, process, screens, design system, results, next project |
about.html |
About | Bio, portrait, numbers, experience timeline, skills, awards |
contact.html |
Contact | Large email link with a copy button, FAQ, project inquiry form |
404.html |
Not found | Styled error page with links back |
Project structure#
folio/
├── index.html, work.html, project.html, about.html, contact.html, 404.html
├── assets/
│ ├── css/style.css compiled Tailwind CSS (generated: don't edit by hand)
│ ├── js/main.js all interactions: menu, reveals, filter, clock, cursor, form, back to top
│ └── img/ SVG artwork, portrait, favicon and social image
├── src/input.css Tailwind source: design tokens (@theme) and component classes
├── package.json scripts to rebuild the CSS
├── template.json template metadata used by freeframe.dev
├── README.md
├── CHANGELOG.md
└── DOCS.md this guideCustomising#
Brand and colours#
All colours and fonts are design tokens in the @theme block at the top of src/input.css:
| Token | Used for |
|---|---|
--color-bg |
Page background (#0b0b0c) |
--color-surface, --color-raised |
Cards and raised panels |
--color-line |
Borders and dividers |
--color-ink |
Main text |
--color-muted |
Secondary text |
--color-accent, --color-accent-soft |
Brand orange: links, highlights, the italic headline word, focus rings |
To switch the accent to a green, change the tokens and rebuild the CSS:
@theme {
--color-accent: #2fbf71;
--color-accent-soft: #6fdc9f;
}The theme-color meta tag in each page's <head> (#0b0b0c) colours the browser UI on phones; update
it if you change --color-bg.
Typography#
Folio uses Inter Tight (text and headings) and Instrument Serif (italic accents), loaded from
Google Fonts with a <link> in each page's <head>:
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=Inter+Tight:wght@300..800&display=swap">To change fonts, replace that link on every page and update --font-sans and --font-serif in
src/input.css, then rebuild the CSS.
Content#
All text is plain HTML in the page files. Names, email address (hello@example.com) and the
city appear on several pages, so a project-wide search and replace is the quickest way to rebrand.
Projects are <article> cards. On work.html each card carries its categories:
<article class="project-card-wrap reveal" data-category="web brand" style="--d:0ms">
<a href="project.html" class="project-card group" data-cursor="View">
...
</a>
</article>data-categoryholds one or more space-separated categories that must match a filter button.style="--d:120ms"staggers the reveal animation; alternate0msand120ms.data-cursor="View"sets the label of the cursor badge for that card.
To add a project, copy an <article>, change the image, title, year and link, and update the count
in the matching filter button (<sup>6</sup>). The home page shows a hand-picked subset of the same
cards.
Case studies: project.html is one complete case study. Duplicate it once per project (for
example project-verde.html) and point each card's href at its copy. The "Next project" link at the
bottom is plain HTML.
Local time: the "Lisbon · 12:00" clocks are elements with data-local-time. The time zone is set
in assets/js/main.js:
var fmt = new Intl.DateTimeFormat('en-GB', { hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Lisbon' });Change timeZone to any IANA zone (for example America/New_York) and update the city text next to
each clock. Elements with data-year are filled with the current year.
Rolling dates. "Booking projects from November 2026" on the contact page is
<span data-months-ahead="1">: the script shows next month and its year, so the availability line never goes
stale. The month in the HTML is shown without JavaScript. To show a fixed month, replace the span with plain text.
Images and artwork#
All artwork in assets/img/ is original SVG:
| File | Where | Aspect ratio |
|---|---|---|
project-*.svg |
Project cards | 16:10 (1600×1000) |
tidal-screens.svg, tidal-system.svg |
Case study screens | 16:9 (1600×900) |
portrait.svg |
About page | Portrait |
favicon.svg |
Browser tab icon | Square |
og-image.svg / og-image.png |
Social sharing image | 1200×630 |
Replace them with your own screenshots or photos (.jpg/.webp) at the same aspect ratio, update the
src, width/height and alt text, and keep loading="lazy" on images below the fold.
Navigation, header and footer#
The header, mobile menu and footer are repeated in every HTML file. To add a page:
- Copy an existing page (for example
about.html) and replace the content inside<main id="main">. - Add a link to the desktop menu (
<nav aria-label="Main">) and the mobile menu (<div id="mobile-menu">) on every page. - Mark the current page's links with
aria-current="page"; the styles highlight it.
Features#
Animated headline and scroll reveals#
Elements with reveal (fade and rise) or reveal-line (the headline's line-by-line mask) start hidden
and get is-visible when they scroll into view. The script uses IntersectionObserver and shows
everything immediately for visitors who prefer reduced motion. Delay an element with the --d custom
property, for example style="--d:240ms".
Work filter#
The filter on work.html is a role="group" with data-filter-group. Each button has
data-filter="product" and aria-pressed; data-filter="all" shows everything. Clicking a button:
- shows cards whose
data-categorycontains that value and fades the rest out; - updates the count in
[data-filter-count](zero-padded, e.g.03); - writes the filter to the URL hash, so
work.html#brandopens with Brand selected.
To add a category, add a button with a new data-filter value and use the same value in cards'
data-category. With reduced motion the cards are hidden and shown without animation.
Cursor badge#
On devices with a mouse or trackpad, a round "View" badge follows the pointer over any element with
data-cursor. It's created by the "Cursor follower" block in assets/js/main.js, is aria-hidden, and
is not created at all on touch devices or with reduced motion. Remove the data-cursor attributes (or
that block) to turn it off.
Mobile menu#
Below 768px the .menu-toggle button opens a full-screen menu (#mobile-menu). It updates
aria-expanded and its label, locks page scrolling, moves focus to the first link and closes with
Esc (returning focus to the button) or when the window grows to desktop width.
Client marquee#
The scrolling client strip on the home page uses the --animate-marquee token (38 seconds per loop)
and pauses on hover. Reduced motion stops it.
Copy email button#
Any button with data-copy="hello@example.com" copies that value to the clipboard and briefly changes
its [data-copy-label] text to "Copied" (or "Press Ctrl+C" if the browser blocks clipboard access).
Change the attribute when you change the email address.
Back to top button#
Every page gets a "Back to top" button in the bottom-right corner, created by the "Back to top" block at
the end of assets/js/main.js and styled by the .back-to-top rules at the end of src/input.css.
- It appears after about one screen of scrolling and hides while the footer's own "Back to top" link is on screen.
- It scrolls to the top (instantly with reduced motion) and moves keyboard focus to
<main>. - Add
data-back-to-top-avoidto anything you pin to the bottom of the screen (a cookie bar, chat launcher) and the button sits above it; adddata-back-to-top-hideto a sticky element it must never cover. - To remove it, delete that JavaScript block and the CSS rules, then rebuild the CSS.
Forms#
contact.html has one form, marked data-validate. It collects:
| Field | name |
Rule |
|---|---|---|
| Project type | type (checkboxes) |
Optional |
| Name | name |
At least 2 characters |
email |
Valid email format | |
| Company | company |
Optional |
| Budget | budget |
A range must be chosen |
| Timeline | timeline |
A timeline must be chosen |
| Message | message |
At least 20 characters |
Errors appear under each field (in #<name>-error, announced to screen readers), fields get
aria-invalid, and the first invalid field is focused. On success the form is hidden and
#form-success is shown with the sender's first name.
The form doesn't send anything until you connect it. To use a form service such as Formspree:
-
Set the form's
actionto your endpoint:html<form class="space-y-10 reveal" action="https://formspree.io/f/your-id" method="post" novalidate data-validate> -
In
assets/js/main.js, find the comment// Demo only: nothing is sent.inside the submit handler and send the data before showing the success message:jsfetch(form.action, { method: 'POST', body: new FormData(form), headers: { Accept: 'application/json' } }).then(function (res) { if (!res.ok) throw new Error('Request failed'); form.hidden = true; success.hidden = false; success.focus(); }).catch(function () { alert('Sorry, the message could not be sent. Please email hello@example.com.'); });
Netlify Forms also works: add data-netlify="true" and name="inquiry" to the <form> and deploy to
Netlify.
SEO and sharing#
Each page's <head> has its own <title>, meta name="description", Open Graph tags
(og:title, og:description, og:image, og:site_name) and twitter:card. Update them for every
page.
og:image points to assets/img/og-image.png with a relative path. Social networks need an absolute
URL, so change it when you deploy:
<meta property="og:image" content="https://example.com/assets/img/og-image.png">The PNG's editable source is assets/img/og-image.svg; export it again at 1200×630 after editing. For
search engines, add a sitemap.xml listing your pages and a robots.txt at the root of your site.
Accessibility#
- A "Skip to content" link jumps to
<main id="main">; focus rings use the accent colour. - Landmarks (
header,navwith labels,main,footer) and one<h1>per page. - Filter buttons use
aria-pressed; the mobile menu usesaria-expandedand closes with Esc. - Form errors are linked with
aria-describedbyand announced witharia-live. - The cursor badge, marquee and reveal animations respect
prefers-reduced-motion.
When editing, keep one <h1> per page, give every new image meaningful alt text (or alt="" if
decorative), label every new input, and keep text contrast at WCAG AA against the dark background.
Building and deploying#
assets/css/style.css is pre-built, so you can edit HTML and text without any tools. Rebuild it only
when you change src/input.css or use Tailwind classes that aren't in the CSS yet. With Node.js 24:
npm install
npm run build # one-off build, minified
npm run watch # rebuild automatically while you editFolio is a static site. Upload the folder (without node_modules/) to any static host:
- Netlify Drop: drag the folder onto app.netlify.com/drop.
- GitHub Pages: push it to a repository and enable Pages for the branch.
- Cloudflare Pages: create a project with "Direct upload". No build command is needed.
Most hosts serve 404.html automatically for missing pages.
Troubleshooting#
- New Tailwind classes have no effect. The CSS only contains classes that were in use when it was
built. Run
npm run build. - A filter button shows nothing. The card's
data-categorymust contain exactly the button'sdata-filtervalue (lowercase, space-separated). work.html#webdoesn't preselect a filter. The hash must match a button'sdata-filter.- The form "sends" but nothing arrives. It's a demo until you connect it; see Forms.
- The clock shows the wrong time. Change
timeZoneinassets/js/main.js. - Shared links have no preview image. Use an absolute URL in
og:image. - Animations don't play. Your system has reduced motion turned on; content is shown without animation by design.
License#
Folio is released under the MIT License (see LICENSE.txt in the download). You can use it for
personal and commercial projects. The included SVG artwork is covered by the same license. Inter Tight
and Instrument Serif are licensed under the SIL Open Font License.