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. This is the Astro 7 + TypeScript + Tailwind CSS v4 version, version 1.0.3. It builds to static HTML files with no UI framework and one small script, and can be hosted anywhere. See it live at freeframe.dev/templates/folio.
Quick start#
Requirements: Node.js 24 or newer and pnpm (npm works too).
pnpm install
pnpm dev # http://localhost:4321With npm: npm install, then npm run dev.
| Command | What it does |
|---|---|
pnpm dev |
Development server at http://localhost:4321 with hot reload |
pnpm build |
Static site in dist/ |
pnpm preview |
Serve dist/ locally after a build |
pnpm check |
astro check: type-checks .astro files and TypeScript |
pnpm typecheck |
Generate Astro's types, then type-check with tsc (TypeScript 7) |
pnpm lint |
ESLint (TypeScript and Astro rules) |
pnpm build:demo |
Build for /demos/astro/folio into demo/ (only used for the demo on freeframe.dev) |
Pages#
Every file in src/pages/ becomes one page.
| URL | File | What's on it |
|---|---|---|
/ |
src/pages/index.astro |
Oversized animated headline, local-time status line, selected work, services, client marquee, testimonial, call to action |
/work |
src/pages/work.astro |
Sticky category filter (All / Product / Web / Brand) with a live project count |
/project |
src/pages/project.astro |
Case study (Tidal Banking): facts, overview, challenge, process, screens, design system, results, next project |
/about |
src/pages/about.astro |
Bio, portrait, numbers, experience timeline, skills, awards |
/contact |
src/pages/contact.astro |
Large email link with a copy button, FAQ, project inquiry form |
| 404 | src/pages/404.astro |
Styled not-found page (most hosts show 404.html automatically) |
Project structure#
folio/
├─ src/
│ ├─ pages/ one .astro file per page (see above)
│ ├─ layouts/
│ │ └─ Layout.astro <head> (title, meta, Open Graph, fonts), skip link, header, footer, scripts
│ ├─ components/
│ │ ├─ SiteHeader.astro logo, desktop navigation, "Start a project" button, full-screen mobile menu
│ │ └─ SiteFooter.astro call to action (optional), links, local time, copyright
│ ├─ lib/
│ │ └─ site.ts site name, theme colour and the url() helper for links
│ ├─ scripts/
│ │ ├─ main.ts menu, reveals, clock, cursor badge, work filter, copy button, form
│ │ └─ back-to-top.ts floating "Back to top" button
│ └─ styles/
│ └─ global.css Tailwind theme (colours, fonts, marquee) and shared component classes
├─ public/img/ project artwork, portrait, favicon.svg, og-image.png (and its .svg source)
├─ astro.config.mjs site URL, base path, output format, Tailwind plugin
├─ eslint.config.mjs, tsconfig.json
├─ package.json, pnpm-workspace.yaml, pnpm-lock.yaml
└─ README.md, DOCS.md, CHANGELOG.md, template.jsonPages are rendered to HTML at build time. The two files in src/scripts/ are the only JavaScript sent
to the browser, plus one inline line in the layout's <head> that adds a js class so the CSS can hide
reveal animations until the script runs. Layout.astro imports both scripts and Astro bundles them into
one small module.
Customising#
Brand and colours#
All colours and fonts are design tokens in the @theme block at the top of src/styles/global.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:
@theme {
--color-accent: #2fbf71;
--color-accent-soft: #6fdc9f;
}Tailwind rebuilds automatically in pnpm dev and pnpm build, so there's no separate CSS step. The
browser theme colour on phones is site.themeColor in src/lib/site.ts; update it if you change
--color-bg.
Typography#
Folio uses Inter Tight (text and headings) and Instrument Serif (italic accents), loaded from
Google Fonts by a <link> in src/layouts/Layout.astro:
<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 and update --font-sans and --font-serif in
src/styles/global.css. To self-host, put the files in public/fonts/, add @font-face rules to
global.css and remove the <link> and the preconnect tags.
Content#
All text is markup in the .astro page files. The name, email address (hello@example.com) and city
appear on several pages and in the header and footer, so a project-wide search and replace is the
quickest way to rebrand. The site name used in og:site_name is site.name in src/lib/site.ts.
Projects are <article> cards. On src/pages/work.astro each card carries its categories:
<article class="project-card-wrap reveal" data-category="product" style="--d:0ms">
<a href={url("/project")} class="project-card group" data-cursor="View">
<div class="project-media aspect-[16/10]">
<img src={url("/img/project-tidal.svg")} alt="Tidal Banking — mobile app design shown on screens" width="1600" height="1000" loading="lazy">
</div>
...
</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: src/pages/project.astro is one complete case study. Copy it once per project (for
example src/pages/project-verde.astro, served at /project-verde) and point each card's href at
url("/project-verde"). The "Next project" link at the bottom is plain markup.
Local time: the "Lisbon · 12:00" clocks are elements with data-local-time. The time zone is set in
src/scripts/main.ts:
const 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="n">: src/scripts/main.ts shows the month n months from now and its year, so
the line never goes stale. The month written in the page is shown without JavaScript. For a fixed date, replace the span
with plain text.
Images and artwork#
All artwork in public/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. Always link
files in public/ through url() (src={url("/img/my-shot.jpg")}) so they work under a base path. For
automatic resizing and WebP output, put images in src/ and use Astro's <Image /> from astro:assets.
The social image is public/img/og-image.png, exported from public/img/og-image.svg; export it again at
1200×630 after editing.
Navigation, header and footer#
src/components/SiteHeader.astroholds the desktop navigation (<nav aria-label="Main">) and the mobile menu (#mobile-menu). A link getsaria-current="page"when the page'scurrentprop matches it (current="work"highlights Work; the home page usescurrent="home").src/components/SiteFooter.astrois the footer. Its large "Have a project in mind?" call to action is shown unless the page passesfooterCta={false}to<Layout>(the contact and 404 pages do).<Layout>also acceptsmainClassfor classes on<main>(used by the 404 page) andnoindex.
To add a page, create src/pages/services.astro:
---
import Layout from "../layouts/Layout.astro";
---
<Layout title="Services — Mira Okafor" description="What I can help you with." current="services">
<section class="container-x pt-40 pb-14 md:pt-48">
<h1 class="text-6xl font-semibold tracking-tight">Services</h1>
</section>
</Layout>Then add a link to both menus in SiteHeader.astro, written as
<a href={url("/services")} aria-current={current === "services" ? "page" : undefined}>.
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 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#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 src/scripts/main.ts, 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
src/scripts/back-to-top.ts and styled by the .back-to-top rules at the end of
src/styles/global.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 the file, its import in
src/layouts/Layout.astroand the.back-to-topstyles.
Forms#
src/pages/contact.astro 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 |
The rules are the rules object in src/scripts/main.ts. 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
actioninsrc/pages/contact.astroto your endpoint:html<form class="space-y-10 reveal" action="https://formspree.io/f/your-id" method="post" novalidate data-validate> -
In
src/scripts/main.ts, find the comment// Demo only: nothing is sent.inside the submit handler and replace the lines after it with a request that shows the success message once it succeeds:tsconst success = document.getElementById("form-success"); fetch(form.action, { method: "POST", body: new FormData(form), headers: { Accept: "application/json" } }) .then((res) => { if (!res.ok) throw new Error("Request failed"); form.hidden = true; if (success) { success.hidden = false; success.focus(); } }) .catch(() => 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. Because the site is static, there's no server code to write; if you need one, add an Astro
adapter and an API endpoint (see Astro endpoints).
SEO and sharing#
-
Each page passes
titleanddescriptionto<Layout>. The layout turns them into<title>, the meta description,og:title,og:description,og:site_nameand asummary_large_imageTwitter card. -
The canonical URL,
og:urland the absoluteog:imageURL are built fromsiteinastro.config.mjs, which readsSITE_URL(defaulthttps://folio.example.com). Set it to your domain when you build:shSITE_URL=https://example.com pnpm buildCanonical URLs have no
.html(https://example.com/work). -
The 404 page passes
noindex, which adds<meta name="robots" content="noindex">and leaves out the canonical URL. -
For a sitemap, add
@astrojs/sitemap(pnpm astro add sitemap); it uses the samesite. Add apublic/robots.txtthat points to it.
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, reveal animations and back-to-top scrolling 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#
pnpm build # static site in dist/
pnpm preview # preview dist/ locallydist/ is plain HTML, CSS and JavaScript. Pages are written as work.html rather than
work/index.html (build.format: "file" in astro.config.mjs), and links point to /work without a
trailing slash. Netlify, Cloudflare Pages and GitHub Pages serve /work from work.html
automatically; Vercel does with cleanUrls (below).
- Netlify / Cloudflare Pages: build command
pnpm build, publish directorydist. - Vercel: import the repository; it detects Astro and static output needs no adapter. Add a
vercel.jsonwith{ "cleanUrls": true }so/workserveswork.html. - GitHub Pages: use the official
Astro GitHub Action. For a project site
(
username.github.io/repo), build withDEMO_BASE_PATH=/repo. - Any static host: upload the contents of
dist/. If the host doesn't map/worktowork.html, changebuild.formatto"directory"inastro.config.mjs.
To serve the site from a sub-folder, set DEMO_BASE_PATH when building, e.g.
DEMO_BASE_PATH=/folio pnpm build. It becomes Astro's base, and every link written with url()
includes it.
demo/ (if present) is the build used for the freeframe.dev demo; you can delete it.
TypeScript 7 setup#
package.json installs two TypeScript packages on purpose. @typescript/native is TypeScript 7 and
provides the tsc that pnpm typecheck runs. typescript is an alias for @typescript/typescript6, the
TypeScript 6 API that astro check and typescript-eslint load. When those tools support TypeScript 7,
replace both with a single typescript@7. tsconfig.json extends Astro's strict preset.
Troubleshooting#
- Links or images break when served from a sub-folder. Build with
DEMO_BASE_PATH, and write internal links and asset paths withurl()fromsrc/lib/site.tsrather than plain"/..."strings. /workshows a 404 on my host. The host doesn't map extensionless URLs to.htmlfiles. Setbuild.format: "directory"inastro.config.mjsand rebuild.- A filter button shows nothing. The card's
data-categorymust contain exactly the button'sdata-filtervalue (lowercase, space-separated). /work#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
timeZoneinsrc/scripts/main.ts. - Content stays invisible.
.revealelements are revealed bysrc/scripts/main.ts; make sureLayout.astrostill imports it and that the console shows no errors. - Social previews show the wrong domain or no image. Set
SITE_URLto your domain at build time.
License#
MIT. The code and all included SVG artwork are free for personal and commercial use; the download
includes the license text in LICENSE.txt. Inter Tight and Instrument Serif are licensed under the SIL
Open Font License.