Freeframe

Documentation

Folio

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

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

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

With 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.json

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

css
@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:

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

html
<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-category holds one or more space-separated categories that must match a filter button.
  • style="--d:120ms" staggers the reveal animation; alternate 0ms and 120ms.
  • 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:

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.

  • src/components/SiteHeader.astro holds the desktop navigation (<nav aria-label="Main">) and the mobile menu (#mobile-menu). A link gets aria-current="page" when the page's current prop matches it (current="work" highlights Work; the home page uses current="home").
  • src/components/SiteFooter.astro is the footer. Its large "Have a project in mind?" call to action is shown unless the page passes footerCta={false} to <Layout> (the contact and 404 pages do).
  • <Layout> also accepts mainClass for classes on <main> (used by the 404 page) and noindex.

To add a page, create src/pages/services.astro:

html
---
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-category contains 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#brand opens 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-avoid to anything you pin to the bottom of the screen (a cookie bar, chat launcher) and the button sits above it; add data-back-to-top-hide to a sticky element it must never cover.
  • To remove it, delete the file, its import in src/layouts/Layout.astro and the .back-to-top styles.

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

  1. Set the form's action in src/pages/contact.astro to your endpoint:

    html
    <form class="space-y-10 reveal" action="https://formspree.io/f/your-id" method="post" novalidate data-validate>
  2. 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:

    ts
    const 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 title and description to <Layout>. The layout turns them into <title>, the meta description, og:title, og:description, og:site_name and a summary_large_image Twitter card.

  • The canonical URL, og:url and the absolute og:image URL are built from site in astro.config.mjs, which reads SITE_URL (default https://folio.example.com). Set it to your domain when you build:

    sh
    SITE_URL=https://example.com pnpm build

    Canonical 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 same site. Add a public/robots.txt that points to it.

Accessibility#

  • A "Skip to content" link jumps to <main id="main">; focus rings use the accent colour.
  • Landmarks (header, nav with labels, main, footer) and one <h1> per page.
  • Filter buttons use aria-pressed; the mobile menu uses aria-expanded and closes with Esc.
  • Form errors are linked with aria-describedby and announced with aria-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#

sh
pnpm build        # static site in dist/
pnpm preview      # preview dist/ locally

dist/ 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 directory dist.
  • Vercel: import the repository; it detects Astro and static output needs no adapter. Add a vercel.json with { "cleanUrls": true } so /work serves work.html.
  • GitHub Pages: use the official Astro GitHub Action. For a project site (username.github.io/repo), build with DEMO_BASE_PATH=/repo.
  • Any static host: upload the contents of dist/. If the host doesn't map /work to work.html, change build.format to "directory" in astro.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 with url() from src/lib/site.ts rather than plain "/..." strings.
  • /work shows a 404 on my host. The host doesn't map extensionless URLs to .html files. Set build.format: "directory" in astro.config.mjs and rebuild.
  • A filter button shows nothing. The card's data-category must contain exactly the button's data-filter value (lowercase, space-separated).
  • /work#web doesn't preselect a filter. The hash must match a button's data-filter.
  • The form "sends" but nothing arrives. It's a demo until you connect it; see Forms.
  • The clock shows the wrong time. Change timeZone in src/scripts/main.ts.
  • Content stays invisible. .reveal elements are revealed by src/scripts/main.ts; make sure Layout.astro still imports it and that the console shows no errors.
  • Social previews show the wrong domain or no image. Set SITE_URL to 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.

Search documentation

Search the guides and every template's documentation