Folio: designer portfolio
A bold five-page portfolio: a huge intro with inline colour pills, a work grid that reveals each result on hover and focus, an about bento, a case study with a sticky fact sheet and contents list, writing and a uses page.
// Ballmac UI: Portfolio template fonts. https://ui.ballmac.com/templates/template-portfolio
import { Bricolage_Grotesque, Geist_Mono } from "next/font/google"
export const portfolioSans = Bricolage_Grotesque({ variable: "--portfolio-sans", subsets: ["latin"], axes: ["opsz", "wdth"], display: "swap", preload: false })
export const portfolioMono = Geist_Mono({ variable: "--portfolio-mono", subsets: ["latin"], display: "swap", preload: false })import type { MediaSource } from "@/components/ballmac/media"
// Ballmac UI: Portfolio sample content. https://ui.ballmac.com/templates/template-portfolio
export type PortfolioProject = {
slug: string
title: string
client: string
year: string
discipline: "Product" | "Brand" | "Systems"
blurb: string
result: string
tags: string[]
/** Which painted cover to draw (0 to 5). */
cover: number
/** Your own image instead of the painted art: an image URL (give it imageAlt), an object with alt text and a dark-mode file, or your own element. */
image?: MediaSource
/** Describes `image` when it is a plain URL. */
imageAlt?: string
}
export const projects: PortfolioProject[] = [
{ slug: "fernhill", title: "Rebuilding onboarding", client: "Fernhill", year: "2026", discipline: "Product", blurb: "A first-run flow that gets teams to their first invoice in one sitting.", result: "+34% activation", tags: ["Product design", "Research", "Prototyping"], cover: 0 },
{ slug: "lumen", title: "A design system in six weeks", client: "Lumen Health", year: "2025", discipline: "Systems", blurb: "Tokens, 60 components and the docs that made engineers actually use them.", result: "-40% handoff time", tags: ["Design systems", "Documentation"], cover: 1 },
{ slug: "paddock", title: "Identity for a riding school", client: "Paddock", year: "2025", discipline: "Brand", blurb: "A mark, a typeface pairing and a handbook for a school that outgrew its logo.", result: "3 offices, 1 look", tags: ["Brand", "Print", "Wayfinding"], cover: 2 },
{ slug: "halcyon", title: "A calmer banking app", client: "Halcyon", year: "2024", discipline: "Product", blurb: "Spending insights that read like a sentence, not a spreadsheet.", result: "4.8 App Store rating", tags: ["Mobile", "Data visualisation"], cover: 3 },
{ slug: "tessera", title: "Pricing page, tested", client: "Tessera", year: "2024", discipline: "Product", blurb: "Six experiments, one clear winner and a page the sales team stopped apologising for.", result: "+22% trial starts", tags: ["Experimentation", "Copy"], cover: 4 },
{ slug: "quanta", title: "Tokens that travel", client: "Quanta", year: "2023", discipline: "Systems", blurb: "A theming layer that let one product ship in nine brands.", result: "9 brands, 1 codebase", tags: ["Design systems", "Tokens"], cover: 5 },
]
export type PortfolioPost = { slug: string; title: string; date: string; read: number; tag: "Process" | "Craft" | "Career"; excerpt: string }
export const posts: PortfolioPost[] = [
{ slug: "p1", title: "The first-run checklist nobody wants to ship", date: "2026-08-14", read: 7, tag: "Process", excerpt: "Why the thing that tests best is usually the thing that annoys the team." },
{ slug: "p2", title: "Stop designing empty states last", date: "2026-05-02", read: 5, tag: "Craft", excerpt: "Empty is the first thing every new user sees. Treat it that way." },
{ slug: "p3", title: "What I learned from deleting 40 components", date: "2026-02-19", read: 9, tag: "Process", excerpt: "A design system is a garden. A small, tidy one beats a sprawling one." },
{ slug: "p4", title: "Going independent after nine years", date: "2025-11-07", read: 6, tag: "Career", excerpt: "The money, the loneliness and the three clients that made it work." },
{ slug: "p5", title: "A defence of the boring button", date: "2025-06-23", read: 4, tag: "Craft", excerpt: "Novelty has a cost. Spend it where it pays." },
{ slug: "p6", title: "How I run a design review", date: "2025-03-11", read: 8, tag: "Process", excerpt: "Four questions, one hour and no one defending their pixels." },
]
export const uses: { group: string; items: { name: string; note: string }[] }[] = [
{ group: "Hardware", items: [{ name: "MacBook Pro 14″", note: "M3 Pro, 36 GB. Mostly plugged into a desk shelf in Lisbon." }, { name: "Studio Display", note: "The only monitor I have trusted for colour." }, { name: "Keychron Q1", note: "Brown switches. Loud enough that my partner has opinions." }, { name: "iPad Pro + Pencil", note: "Sketches, critique markup and reading." }] },
{ group: "Software", items: [{ name: "Figma", note: "Design, prototypes and the occasional diagram." }, { name: "Linear", note: "Where projects and promises go to be counted." }, { name: "Things", note: "Personal tasks. Never work tasks." }, { name: "Raycast", note: "Launcher, clipboard history and window layouts." }] },
{ group: "Type", items: [{ name: "Bricolage Grotesque", note: "This site, because I wanted something with a personality." }, { name: "Geist Mono", note: "Small labels and code." }, { name: "Söhne", note: "For client work where budget allows." }] },
{ group: "Reading", items: [{ name: "Refactoring UI", note: "Still the best short book on interface design." }, { name: "The Design of Everyday Things", note: "Reread every two years." }, { name: "Field notebooks", note: "Paper, always. Three at a time." }] },
]// Ballmac UI: Portfolio template shell. https://ui.ballmac.com/templates/template-portfolio
"use client"
import * as React from "react"
import { ArrowUpRight, Menu, X } from "lucide-react"
import { CopyButton } from "@/components/ballmac/copy-button"
import { portfolioMono, portfolioSans } from "@/components/ballmac/templates/portfolio/portfolio-fonts"
import { cn } from "@/lib/utils"
import { Media, type MediaSource } from "@/components/ballmac/media"
type PortfolioPage = "home" | "work" | "case" | "writing" | "uses"
type PortfolioHrefs = Record<PortfolioPage, string>
const defaultHrefs: PortfolioHrefs = { home: "/portfolio", work: "/portfolio/work", case: "/portfolio/work/fernhill", writing: "/portfolio/writing", uses: "/portfolio/uses" }
/** Portfolio's palette: gallery white and ink with one acid lime. In dark mode the lime becomes the light on a black wall. */
const portfolioCss = `
.portfolio-theme,body:not(:has([data-gallery])):has(.portfolio-theme){--background:oklch(0.988 0.003 100);--foreground:oklch(0.16 0.01 100);--card:oklch(1 0 0);--card-foreground:oklch(0.16 0.01 100);--popover:oklch(1 0 0);--popover-foreground:oklch(0.16 0.01 100);--primary:oklch(0.16 0.01 100);--primary-foreground:oklch(0.988 0.003 100);--secondary:oklch(0.958 0.008 100);--secondary-foreground:oklch(0.16 0.01 100);--muted:oklch(0.958 0.008 100);--muted-foreground:oklch(0.46 0.012 100);--accent:oklch(0.94 0.014 110);--accent-foreground:oklch(0.16 0.01 100);--border:oklch(0.16 0.01 100 / 14%);--input:oklch(0.16 0.01 100 / 20%);--ring:oklch(0.16 0.01 100);--surface:oklch(0.968 0.006 100);--destructive:oklch(0.54 0.22 28);--chart-1:oklch(0.9 0.19 125);--chart-2:oklch(0.72 0.17 38);--chart-3:oklch(0.78 0.12 235);--chart-4:oklch(0.76 0.13 300);--chart-5:oklch(0.86 0.09 75);--portfolio-on-accent:oklch(0.16 0.01 100);--radius:1rem}
.dark .portfolio-theme,.dark body:not(:has([data-gallery])):has(.portfolio-theme){--background:oklch(0.14 0.004 100);--foreground:oklch(0.96 0.006 100);--card:oklch(0.18 0.005 100);--card-foreground:oklch(0.96 0.006 100);--popover:oklch(0.2 0.006 100);--popover-foreground:oklch(0.96 0.006 100);--primary:oklch(0.96 0.006 100);--primary-foreground:oklch(0.16 0.01 100);--secondary:oklch(0.23 0.006 100);--secondary-foreground:oklch(0.96 0.006 100);--muted:oklch(0.22 0.006 100);--muted-foreground:oklch(0.72 0.012 100);--accent:oklch(0.26 0.01 110);--accent-foreground:oklch(0.96 0.006 100);--border:oklch(1 0 0 / 12%);--input:oklch(1 0 0 / 16%);--ring:oklch(0.9 0.19 125);--surface:oklch(0.165 0.005 100);--destructive:oklch(0.7 0.19 28);--chart-1:oklch(0.9 0.19 125);--chart-2:oklch(0.72 0.17 38);--chart-3:oklch(0.76 0.12 235);--chart-4:oklch(0.74 0.13 300);--chart-5:oklch(0.84 0.09 75);--portfolio-on-accent:oklch(0.16 0.01 100)}
body:not(:has([data-gallery])):has(.portfolio-theme){font-family:var(--portfolio-sans),ui-sans-serif,system-ui,sans-serif}
`
/** A painted cover: abstract shapes in the chart colours standing in for a screenshot. */
function Cover({ variant, className, image, imageAlt }: { variant: number; className?: string; image?: MediaSource; imageAlt?: string }) {
const v = ((variant % 6) + 6) % 6
if (image) return <Media media={image} alt={imageAlt} fill className={cn("aspect-[4/3] w-full", className)} />
return (
<div aria-hidden="true" className={cn("relative isolate aspect-[4/3] w-full overflow-hidden", className)}>
{v === 0 && (<><div className="bg-chart-5 absolute inset-0" /><div className="bg-card absolute inset-x-[12%] top-[14%] bottom-0 rounded-t-2xl shadow-xl"><div className="bg-chart-1 m-[7%] h-[14%] w-[40%] rounded-md" /><div className="bg-muted mx-[7%] h-[8%] w-[70%] rounded" /><div className="bg-muted mx-[7%] mt-[3%] h-[8%] w-[54%] rounded" /><div className="mx-[7%] mt-[7%] grid grid-cols-3 gap-[3%]">{[0, 1, 2].map((i) => <div key={i} className="bg-secondary aspect-square rounded-lg" />)}</div></div></>)}
{v === 1 && (<><div className="bg-foreground absolute inset-0" /><div className="absolute inset-[10%] grid grid-cols-4 gap-[4%]">{[chart(1), chart(2), chart(3), chart(4), chart(5), chart(1), chart(3), chart(2)].map((c, i) => <div key={i} className={cn("rounded-xl", c)} />)}</div></>)}
{v === 2 && (<><div className="bg-chart-2 absolute inset-0" /><div className="absolute top-1/2 left-1/2 aspect-square w-[46%] -translate-x-1/2 -translate-y-1/2"><div className="bg-primary-foreground size-full rounded-full" /><div className="bg-foreground absolute inset-y-0 start-0 w-1/2 rounded-s-full" /></div></>)}
{v === 3 && (<><div className="bg-chart-3 absolute inset-0" /><div className="bg-foreground absolute top-[10%] bottom-[-8%] left-1/2 w-[38%] -translate-x-1/2 rounded-[2rem] p-[3%]"><div className="bg-card h-full rounded-[1.4rem] p-[8%]"><div className="bg-chart-1 h-[10%] w-[60%] rounded" /><div className="mt-[10%] flex h-[34%] items-end gap-[6%]">{[40, 70, 55, 90, 65].map((h, i) => <div key={i} className="bg-foreground flex-1 rounded-t" style={{ height: `${h}%` }} />)}</div></div></div></>)}
{v === 4 && (<><div className="bg-chart-4 absolute inset-0" /><div className="absolute inset-[12%] grid grid-cols-3 gap-[4%]">{[0, 1, 2].map((i) => <div key={i} className={cn("bg-card rounded-2xl p-[10%]", i === 1 && "bg-foreground -translate-y-[6%]")}><div className={cn("h-[10%] w-[60%] rounded", i === 1 ? "bg-chart-1" : "bg-muted")} /><div className={cn("mt-[18%] h-[22%] w-[50%] rounded", i === 1 ? "bg-primary-foreground" : "bg-secondary")} /></div>)}</div></>)}
{v === 5 && (<><div className="bg-secondary absolute inset-0" />{[0, 1, 2, 3, 4].map((i) => <div key={i} className={cn("absolute w-[34%] aspect-square rounded-full mix-blend-multiply", chart((i % 5) + 1))} style={{ left: `${8 + i * 14}%`, top: `${i % 2 ? 40 : 16}%` }} />)}</>)}
</div>
)
}
function chart(n: number) {
return ["bg-chart-1", "bg-chart-2", "bg-chart-3", "bg-chart-4", "bg-chart-5"][(n - 1) % 5]
}
const links: { key: PortfolioPage; label: string }[] = [
{ key: "work", label: "Work" },
{ key: "writing", label: "Writing" },
{ key: "uses", label: "Uses" },
]
type PortfolioShellProps = React.ComponentProps<"div"> & {
/** The page being shown, so its nav link is marked current. */
page: PortfolioPage
/** Override where pages live (used by previews). */
hrefs?: Partial<PortfolioHrefs>
}
/** Portfolio's frame: a wordmark with an availability chip, a quiet nav and a huge email in the footer. */
function PortfolioShell({ page, hrefs: overrides, className, style, children, ...props }: PortfolioShellProps) {
const hrefs = { ...defaultHrefs, ...overrides }
const [open, setOpen] = React.useState(false)
React.useEffect(() => {
const classes = [portfolioSans.variable, portfolioMono.variable].filter(Boolean)
document.body.classList.add(...classes)
return () => document.body.classList.remove(...classes)
}, [])
const active = page === "case" ? "work" : page
return (
<div
data-slot="portfolio"
className={cn("portfolio-theme bg-background text-foreground relative min-h-dvh overflow-x-clip", portfolioSans.variable, portfolioMono.variable, className)}
style={{ fontFamily: "var(--portfolio-sans), ui-sans-serif, system-ui, sans-serif", ...style }}
{...props}
>
<style>{portfolioCss}</style>
<header className="bg-background/80 sticky top-0 z-40 backdrop-blur-xl">
<div className="mx-auto flex h-16 max-w-7xl items-center justify-between px-4 sm:px-8">
<a href={hrefs.home} className="focus-visible:ring-ring/50 flex items-center gap-3 rounded-lg outline-none focus-visible:ring-[3px]">
<span className="bg-chart-1 text-[var(--portfolio-on-accent)] flex size-9 items-center justify-center rounded-full text-sm font-bold tracking-tight" aria-hidden="true">IC</span>
<span className="font-semibold tracking-tight">Ines Calder</span>
</a>
<span className="text-muted-foreground hidden items-center gap-2 text-xs lg:flex" style={{ fontFamily: "var(--portfolio-mono)" }}>
<span className="relative flex size-2"><span className="bg-chart-2 absolute inset-0 rounded-full" /><span className="bg-chart-2 absolute inset-0 rounded-full opacity-60 motion-safe:animate-ping" /></span>
Booking Q1 2027 · Lisbon
</span>
<nav aria-label="Main" className="hidden items-center gap-1 md:flex">
{links.map((l) => (
<a key={l.key} href={hrefs[l.key]} aria-current={active === l.key ? "page" : undefined} className="text-muted-foreground hover:text-foreground aria-[current=page]:text-foreground aria-[current=page]:bg-accent focus-visible:ring-ring/50 rounded-full px-4 py-2 text-sm font-medium outline-none transition-colors focus-visible:ring-[3px]">
{l.label}
</a>
))}
<a href="#contact" className="bg-foreground text-background focus-visible:ring-ring/50 ms-2 inline-flex h-10 items-center gap-1.5 rounded-full px-5 text-sm font-semibold outline-none transition-opacity hover:opacity-90 focus-visible:ring-[3px]">Say hello <ArrowUpRight className="size-4 rtl:-scale-x-100" aria-hidden="true" /></a>
</nav>
<button type="button" aria-label={open ? "Close menu" : "Open menu"} aria-expanded={open} aria-controls="portfolio-mobile-menu" onClick={() => setOpen((v) => !v)} className="hover:bg-accent focus-visible:ring-ring/50 inline-flex size-10 items-center justify-center rounded-full outline-none focus-visible:ring-[3px] md:hidden">
{open ? <X className="size-5" aria-hidden="true" /> : <Menu className="size-5" aria-hidden="true" />}
</button>
</div>
{open && (
<nav id="portfolio-mobile-menu" aria-label="Mobile" className="px-4 pb-4 md:hidden">
{[{ key: "home" as const, label: "Home" }, ...links].map((l) => <a key={l.key} href={hrefs[l.key]} className="hover:bg-accent block rounded-2xl px-4 py-3 text-lg font-medium">{l.label}</a>)}
</nav>
)}
</header>
{children}
<footer id="contact" className="mt-24 border-t">
<div className="mx-auto max-w-7xl px-4 pt-16 pb-10 sm:px-8">
<p className="text-muted-foreground text-sm" style={{ fontFamily: "var(--portfolio-mono)" }}>Have a product that needs care?</p>
<div className="mt-4 flex flex-wrap items-center gap-3">
<a href="mailto:hello@inescalder.example" className="focus-visible:ring-ring/50 rounded-xl text-[clamp(2rem,7.4vw,6.5rem)] leading-none font-semibold tracking-[-0.045em] break-all outline-none hover:underline hover:decoration-[0.08em] hover:underline-offset-[0.12em] focus-visible:ring-[3px]">hello@inescalder.example</a>
<CopyButton value="hello@inescalder.example" ariaLabel="Copy email address" className="size-11" />
</div>
<div className="text-muted-foreground mt-14 flex flex-wrap items-center justify-between gap-4 border-t pt-6 text-sm">
<p>© 2026 Ines Calder. Designed and built in Lisbon.</p>
<ul className="flex gap-5">{["LinkedIn", "Read.cv", "Are.na", "RSS"].map((l) => <li key={l}><a href="#" className="hover:text-foreground transition-colors">{l}</a></li>)}</ul>
</div>
</div>
</footer>
</div>
)
}
export { Cover, PortfolioShell, defaultHrefs as portfolioDefaultHrefs, type PortfolioHrefs, type PortfolioPage, type PortfolioShellProps }// Ballmac UI: Portfolio home page. https://ui.ballmac.com/templates/template-portfolio
"use client"
import * as React from "react"
import { ArrowDownRight, ArrowUpRight, BookOpen, Headphones, Hammer, MapPin } from "lucide-react"
import { BlurFade } from "@/components/ballmac/blur-fade"
import { Marquee } from "@/components/ballmac/marquee"
import { posts, projects } from "@/components/ballmac/templates/portfolio/portfolio-data"
import { Cover, PortfolioShell, type PortfolioHrefs } from "@/components/ballmac/templates/portfolio/portfolio-theme"
import { cn } from "@/lib/utils"
const mono = { fontFamily: "var(--portfolio-mono)" } as const
const quotes = [
["Ines made the hard thing feel obvious. Our activation rate said so.", "Sasha Petrova, Fernhill"],
["The calmest designer I have ever worked with, and the sharpest.", "Marcus Lindqvist, Halcyon"],
["She cut our component library in half and everyone thanked her.", "Priya Raman, Lumen Health"],
["We hired Ines for a logo and got a company worth working for.", "Tom Beltrán, Paddock"],
]
function Pill({ className, children }: { className?: string; children?: React.ReactNode }) {
return <span aria-hidden="true" className={cn("mx-1 inline-block h-[0.72em] w-[1.6em] rounded-full align-[-0.06em] sm:mx-2", className)}>{children}</span>
}
type PortfolioHomeProps = React.ComponentProps<"div"> & { hrefs?: Partial<PortfolioHrefs> }
/** The Portfolio home page: a bold intro, a work grid whose cards reveal a result on hover and focus, an about bento, kind words and recent writing. */
function PortfolioHome({ hrefs, ...props }: PortfolioHomeProps) {
const h = { work: hrefs?.work ?? "/portfolio/work", case: hrefs?.case ?? "/portfolio/work/fernhill", writing: hrefs?.writing ?? "/portfolio/writing" }
return (
<PortfolioShell page="home" hrefs={hrefs} {...props}>
<main>
<section className="mx-auto max-w-7xl px-4 pt-14 pb-10 sm:px-8 sm:pt-24">
<BlurFade>
<p className="text-muted-foreground text-sm" style={mono}>Independent product designer · 9 years · Lisbon</p>
<h1 className="mt-6 max-w-6xl text-[clamp(2.6rem,8.6vw,8rem)] leading-[0.94] font-semibold tracking-[-0.05em] text-balance">
I design products people <Pill className="bg-chart-1" />finish <Pill className="bg-chart-2" />using, and teams <Pill className="bg-chart-3" />enjoy shipping.
</h1>
<div className="mt-10 flex flex-wrap items-center gap-3">
<a href="#work" className="bg-foreground text-background focus-visible:ring-ring/50 inline-flex h-12 items-center gap-2 rounded-full px-6 font-semibold outline-none transition-opacity hover:opacity-90 focus-visible:ring-[3px]">Selected work <ArrowDownRight className="size-4 rtl:-scale-x-100" aria-hidden="true" /></a>
<a href={h.writing} className="hover:bg-accent focus-visible:ring-ring/50 inline-flex h-12 items-center rounded-full border px-6 font-semibold outline-none transition-colors focus-visible:ring-[3px]">Read my writing</a>
</div>
</BlurFade>
</section>
<section aria-label="Skills" className="border-y py-5">
<Marquee speed={40} gap={40} pauseOnHover>
{["Product design", "Design systems", "Prototyping", "Research", "Brand", "Workshops", "Art direction", "Front-end"].map((s) => <span key={s} className="text-muted-foreground flex items-center gap-10 text-2xl font-medium tracking-tight sm:text-3xl">{s}<span className="bg-chart-1 size-2.5 rounded-full" aria-hidden="true" /></span>)}
</Marquee>
</section>
<section id="work" aria-labelledby="pf-work" className="mx-auto max-w-7xl scroll-mt-20 px-4 py-20 sm:px-8">
<div className="flex items-end justify-between gap-4">
<h2 id="pf-work" className="text-4xl font-semibold tracking-[-0.04em] sm:text-6xl">Selected work</h2>
<a href={h.work} className="focus-visible:ring-ring/50 inline-flex items-center gap-1.5 rounded-full text-sm font-semibold underline-offset-4 outline-none hover:underline focus-visible:ring-[3px]">All projects <ArrowUpRight className="size-4 rtl:-scale-x-100" aria-hidden="true" /></a>
</div>
<ul className="mt-10 grid gap-x-6 gap-y-12 sm:grid-cols-2">
{projects.slice(0, 4).map((p, i) => (
<li key={p.slug} className={cn(i % 2 === 1 && "sm:mt-16")}>
<a href={h.case} className="group focus-visible:ring-ring/50 block rounded-3xl outline-none focus-visible:ring-[3px] focus-visible:ring-offset-4 focus-visible:ring-offset-background">
<div className="relative overflow-hidden rounded-3xl border">
<Cover variant={p.cover} image={p.image} imageAlt={p.imageAlt} className="transition-transform duration-700 ease-out group-hover:scale-[1.04] group-focus-visible:scale-[1.04] motion-reduce:transition-none" />
<div className="bg-chart-1 text-[var(--portfolio-on-accent)] absolute end-4 bottom-4 flex translate-y-3 items-center gap-2 rounded-full px-4 py-2 text-sm font-bold opacity-0 shadow-lg transition-all duration-300 group-hover:translate-y-0 group-hover:opacity-100 group-focus-visible:translate-y-0 group-focus-visible:opacity-100 motion-reduce:transition-none">
{p.result} <ArrowUpRight className="size-4 rtl:-scale-x-100" aria-hidden="true" />
</div>
</div>
<div className="mt-5 flex items-start justify-between gap-4">
<div>
<h3 className="text-2xl font-semibold tracking-[-0.03em] text-balance">{p.title}</h3>
<p className="text-muted-foreground mt-1 text-pretty">{p.blurb}</p>
</div>
<p className="text-muted-foreground shrink-0 text-sm" style={mono}>{p.client}<br />{p.year}</p>
</div>
<p className="sr-only">Result: {p.result}</p>
</a>
</li>
))}
</ul>
</section>
<section aria-labelledby="pf-about" className="mx-auto max-w-7xl px-4 pb-20 sm:px-8">
<h2 id="pf-about" className="sr-only">About</h2>
<div className="grid gap-4 md:grid-cols-4 md:grid-rows-2">
<div className="bg-chart-1 text-[var(--portfolio-on-accent)] relative flex min-h-72 flex-col justify-end overflow-hidden rounded-3xl p-7 md:col-span-2 md:row-span-2">
<div aria-hidden="true" className="absolute -top-8 -end-8 size-56 rounded-full border-[28px] border-current opacity-15" />
<div aria-hidden="true" className="absolute top-6 start-6 flex size-24 items-center justify-center rounded-full bg-primary text-4xl font-semibold text-background">IC</div>
<p className="text-3xl leading-tight font-semibold tracking-[-0.03em] text-balance sm:text-4xl">Hi, I’m Ines. I turn tangled products into ones that feel obvious.</p>
<p className="mt-3 max-w-md text-pretty opacity-80">Nine years across fintech, health and developer tools. Before going independent I led design at Fernhill and Lumen Health.</p>
</div>
<div className="bg-card rounded-3xl border p-6">
<p className="text-muted-foreground flex items-center gap-2 text-xs" style={mono}><MapPin className="size-3.5" aria-hidden="true" />Based in</p>
<p className="mt-3 text-3xl font-semibold tracking-[-0.03em]">Lisbon</p>
<p className="text-muted-foreground mt-1 text-sm">Working across Europe and the US East Coast.</p>
</div>
<div className="bg-foreground text-background rounded-3xl p-6">
<dl className="space-y-5">
<div><dd className="text-5xl font-semibold tracking-[-0.04em] tabular-nums">9</dd><dt className="text-sm opacity-75">years designing</dt></div>
<div><dd className="text-5xl font-semibold tracking-[-0.04em] tabular-nums">40+</dd><dt className="text-sm opacity-75">products launched</dt></div>
</dl>
</div>
<div className="bg-card rounded-3xl border p-6 md:col-span-2">
<p className="text-muted-foreground text-xs" style={mono}>Right now</p>
<ul className="mt-4 grid gap-4 sm:grid-cols-3">
{[[Hammer, "Building", "A pricing experiment kit for Tessera"], [BookOpen, "Reading", "The Timeless Way of Building"], [Headphones, "Listening", "Fela Kuti, on repeat"]].map(([Icon, k, v]) => {
const I = Icon as typeof Hammer
return <li key={k as string}><I className="size-5" aria-hidden="true" /><p className="mt-3 text-sm font-semibold">{k as string}</p><p className="text-muted-foreground text-sm text-pretty">{v as string}</p></li>
})}
</ul>
</div>
</div>
</section>
<section aria-label="Kind words" className="border-y py-10">
<Marquee speed={32} gap={48} reverse pauseOnHover>
{quotes.map(([q, who]) => (
<figure key={who} className="w-[26rem] max-w-[80vw]">
<blockquote className="text-xl leading-snug font-medium tracking-[-0.02em] text-balance">“{q}”</blockquote>
<figcaption className="text-muted-foreground mt-3 text-sm" style={mono}>{who}</figcaption>
</figure>
))}
</Marquee>
</section>
<section aria-labelledby="pf-writing" className="mx-auto max-w-7xl px-4 pt-20 sm:px-8">
<div className="flex items-end justify-between gap-4">
<h2 id="pf-writing" className="text-4xl font-semibold tracking-[-0.04em] sm:text-5xl">Recent writing</h2>
<a href={h.writing} className="focus-visible:ring-ring/50 inline-flex items-center gap-1.5 rounded-full text-sm font-semibold underline-offset-4 outline-none hover:underline focus-visible:ring-[3px]">All posts <ArrowUpRight className="size-4 rtl:-scale-x-100" aria-hidden="true" /></a>
</div>
<ul className="mt-8 divide-y border-y">
{posts.slice(0, 3).map((p) => (
<li key={p.slug}>
<a href={h.writing} className="hover:bg-accent/60 focus-visible:ring-ring/50 group -mx-3 grid grid-cols-[1fr_auto] items-baseline gap-4 rounded-2xl px-3 py-5 outline-none transition-colors focus-visible:ring-[3px] sm:grid-cols-[1fr_8rem_6rem]">
<span className="text-xl font-semibold tracking-[-0.02em] text-balance sm:text-2xl">{p.title}</span>
<span className="text-muted-foreground hidden text-sm sm:block" style={mono}>{p.tag}</span>
<span className="text-muted-foreground text-end text-sm tabular-nums" style={mono}>{p.read} min</span>
</a>
</li>
))}
</ul>
</section>
</main>
</PortfolioShell>
)
}
export { PortfolioHome, type PortfolioHomeProps }// Ballmac UI: Portfolio work page. https://ui.ballmac.com/templates/template-portfolio
"use client"
import * as React from "react"
import { ArrowUpRight } from "lucide-react"
import { projects } from "@/components/ballmac/templates/portfolio/portfolio-data"
import { Cover, PortfolioShell, type PortfolioHrefs } from "@/components/ballmac/templates/portfolio/portfolio-theme"
import { cn } from "@/lib/utils"
type Filter = "All" | "Product" | "Brand" | "Systems"
const filters: Filter[] = ["All", "Product", "Systems", "Brand"]
type PortfolioWorkProps = React.ComponentProps<"div"> & { hrefs?: Partial<PortfolioHrefs> }
/** The Portfolio work page: every project, filtered by discipline, in a staggered grid with a result on hover. */
function PortfolioWork({ hrefs, ...props }: PortfolioWorkProps) {
const [filter, setFilter] = React.useState<Filter>("All")
const shown = projects.filter((p) => filter === "All" || p.discipline === filter)
const caseHref = hrefs?.case ?? "/portfolio/work/fernhill"
return (
<PortfolioShell page="work" hrefs={hrefs} {...props}>
<main className="mx-auto max-w-7xl px-4 pt-14 sm:px-8 sm:pt-20">
<p className="text-muted-foreground text-sm" style={{ fontFamily: "var(--portfolio-mono)" }}>Work · 2023 to 2026</p>
<h1 className="mt-4 max-w-4xl text-[clamp(2.6rem,7vw,6rem)] leading-[0.95] font-semibold tracking-[-0.05em] text-balance">Six projects I’m proud of, and what they changed.</h1>
<div role="group" aria-label="Filter by discipline" className="mt-10 flex flex-wrap gap-2">
{filters.map((f) => {
const n = f === "All" ? projects.length : projects.filter((p) => p.discipline === f).length
return (
<button key={f} type="button" aria-pressed={filter === f} onClick={() => setFilter(f)} className={cn("focus-visible:ring-ring/50 h-11 rounded-full border px-5 text-sm font-semibold outline-none transition-colors focus-visible:ring-[3px]", filter === f ? "bg-foreground text-background border-transparent" : "hover:bg-accent")}>
{f} <span className={cn("ms-1 tabular-nums", filter === f ? "opacity-70" : "text-muted-foreground")}>{n}</span>
</button>
)
})}
</div>
<p className="sr-only" role="status">{shown.length} projects shown</p>
<ul className="mt-12 grid gap-x-6 gap-y-14 sm:grid-cols-2 lg:grid-cols-3">
{shown.map((p) => (
<li key={p.slug}>
<a href={caseHref} className="group focus-visible:ring-ring/50 block rounded-3xl outline-none focus-visible:ring-[3px] focus-visible:ring-offset-4 focus-visible:ring-offset-background">
<div className="relative overflow-hidden rounded-3xl border">
<Cover variant={p.cover} image={p.image} imageAlt={p.imageAlt} className="transition-transform duration-700 group-hover:scale-[1.04] group-focus-visible:scale-[1.04] motion-reduce:transition-none" />
<span className="bg-chart-1 text-[var(--portfolio-on-accent)] absolute end-3 bottom-3 inline-flex translate-y-2 items-center gap-1.5 rounded-full px-3.5 py-1.5 text-xs font-bold opacity-0 transition-all duration-300 group-hover:translate-y-0 group-hover:opacity-100 group-focus-visible:translate-y-0 group-focus-visible:opacity-100 motion-reduce:transition-none">{p.result}<ArrowUpRight className="size-3.5 rtl:-scale-x-100" aria-hidden="true" /></span>
</div>
<div className="mt-4">
<p className="text-muted-foreground text-xs" style={{ fontFamily: "var(--portfolio-mono)" }}>{p.client} · {p.year} · {p.discipline}</p>
<h2 className="mt-1.5 text-xl font-semibold tracking-[-0.025em] text-balance">{p.title}</h2>
<p className="text-muted-foreground mt-1 text-sm text-pretty">{p.blurb}</p>
<p className="sr-only">Result: {p.result}</p>
</div>
</a>
</li>
))}
</ul>
</main>
</PortfolioShell>
)
}
export { PortfolioWork, type PortfolioWorkProps }// Ballmac UI: Portfolio case study page. https://ui.ballmac.com/templates/template-portfolio
"use client"
import * as React from "react"
import { ArrowRight } from "lucide-react"
import { TableOfContents } from "@/components/ballmac/table-of-contents"
import { projects } from "@/components/ballmac/templates/portfolio/portfolio-data"
import { Cover, PortfolioShell, type PortfolioHrefs } from "@/components/ballmac/templates/portfolio/portfolio-theme"
import { cn } from "@/lib/utils"
const mono = { fontFamily: "var(--portfolio-mono)" } as const
const h2 = "scroll-mt-24 text-3xl font-semibold tracking-[-0.035em] text-balance sm:text-4xl"
const body = "text-muted-foreground mt-5 max-w-2xl text-lg leading-relaxed text-pretty"
const meta = [
["Role", "Lead product designer"],
["Team", "2 designers, 5 engineers, 1 PM"],
["Timeline", "14 weeks, Mar to Jun 2026"],
["Tools", "Figma, Linear, Maze, Framer"],
]
const process = [
["Week 1–2", "Listen", "Watched 14 new teams try to get started. Nine gave up before inviting a colleague."],
["Week 3–5", "Frame", "Reduced the goal to one sentence: send your first invoice in one sitting."],
["Week 6–10", "Build and test", "Four prototypes, 22 tests. We cut the setup from nine steps to three."],
["Week 11–14", "Ship and measure", "Rolled out behind a flag to 10, then 50, then 100 percent of new signups."],
]
const results = [["+34%", "of new teams activate in their first week"], ["−58%", "time to first invoice"], ["9 → 3", "setup steps"], ["4.7", "onboarding satisfaction, up from 3.9"]]
type PortfolioCaseProps = React.ComponentProps<"div"> & { hrefs?: Partial<PortfolioHrefs> }
/** The Portfolio case study: a sticky fact sheet, a contents list that follows your reading, the process as a timeline and results in big numbers. */
function PortfolioCase({ hrefs, ...props }: PortfolioCaseProps) {
const next = projects[1]
return (
<PortfolioShell page="case" hrefs={hrefs} {...props}>
<main>
<header className="mx-auto max-w-7xl px-4 pt-14 sm:px-8 sm:pt-20">
<p className="text-muted-foreground text-sm" style={mono}><a href={hrefs?.work ?? "/portfolio/work"} className="underline-offset-4 hover:underline">Work</a> / Fernhill</p>
<h1 className="mt-4 max-w-5xl text-[clamp(2.6rem,7.4vw,6.5rem)] leading-[0.95] font-semibold tracking-[-0.05em] text-balance">Rebuilding onboarding so teams reach their first invoice in one sitting.</h1>
<Cover variant={0} className="mt-12 aspect-[16/8] rounded-3xl border" />
</header>
<div className="mx-auto mt-16 grid max-w-7xl gap-12 px-4 sm:px-8 lg:grid-cols-[16rem_minmax(0,1fr)] xl:grid-cols-[16rem_minmax(0,1fr)_14rem]">
<aside className="lg:sticky lg:top-24 lg:self-start">
<dl className="space-y-5">
{meta.map(([k, v]) => <div key={k}><dt className="text-muted-foreground text-xs" style={mono}>{k}</dt><dd className="mt-1 text-sm font-medium text-pretty">{v}</dd></div>)}
</dl>
</aside>
<article className="min-w-0 space-y-20">
<section aria-labelledby="overview">
<h2 id="overview" className={h2}>Overview</h2>
<p className={body}>Fernhill is invoicing software for small studios. It was good at the second month and bad at the first day: almost half of new teams never sent an invoice. I led a 14-week rebuild of the first-run experience.</p>
</section>
<section aria-labelledby="problem">
<h2 id="problem" className={h2}>The problem</h2>
<p className={body}>The old flow asked for nine things before it showed any value: company details, tax settings, a logo, bank info, a template, a first customer. Every step was reasonable. Together they were a wall.</p>
<blockquote className="bg-chart-1 text-[var(--portfolio-on-accent)] mt-8 max-w-2xl rounded-3xl p-8 text-2xl leading-snug font-semibold tracking-[-0.025em] text-balance">“I just wanted to send one invoice. I closed the tab at step six.”<footer className="mt-4 text-sm font-medium opacity-75" style={mono}>Participant 7, studio owner</footer></blockquote>
</section>
<section aria-labelledby="process">
<h2 id="process" className={h2}>Process</h2>
<ol className="mt-8 divide-y border-y">
{process.map(([when, title, text]) => (
<li key={title} className="grid gap-2 py-6 sm:grid-cols-[8rem_10rem_1fr] sm:gap-6">
<span className="text-muted-foreground text-sm" style={mono}>{when}</span>
<span className="text-xl font-semibold tracking-[-0.02em]">{title}</span>
<span className="text-muted-foreground text-pretty">{text}</span>
</li>
))}
</ol>
</section>
<section aria-labelledby="solution">
<h2 id="solution" className={h2}>The solution</h2>
<p className={body}>One screen asks the only question that matters: who are you invoicing? Everything else is a default you can change later, and the first invoice is a draft by the time you finish typing the customer’s name.</p>
<div className="mt-8 grid gap-4 sm:grid-cols-2"><Cover variant={4} className="rounded-3xl border" /><Cover variant={3} className="rounded-3xl border" /></div>
</section>
<section aria-labelledby="results">
<h2 id="results" className={h2}>Results</h2>
<dl className="mt-8 grid gap-4 sm:grid-cols-2">
{results.map(([v, l], i) => (
<div key={l} className={cn("rounded-3xl p-7", i === 0 ? "bg-chart-1 text-[var(--portfolio-on-accent)]" : "bg-secondary")}>
<dd className="text-6xl font-semibold tracking-[-0.05em] tabular-nums">{v}</dd>
<dt className={cn("mt-2 text-pretty", i === 0 ? "opacity-80" : "text-muted-foreground")}>{l}</dt>
</div>
))}
</dl>
</section>
<section aria-labelledby="reflection">
<h2 id="reflection" className={h2}>What I’d do differently</h2>
<p className={body}>I would have involved sales earlier. They knew which questions prospects asked on day one, and two of them became default answers we had to bolt on after launch.</p>
</section>
</article>
<aside className="hidden xl:block">
<div className="sticky top-24">
<TableOfContents title="On this page" offset={96} items={[{ id: "overview", title: "Overview" }, { id: "problem", title: "The problem" }, { id: "process", title: "Process" }, { id: "solution", title: "The solution" }, { id: "results", title: "Results" }, { id: "reflection", title: "Reflection" }]} />
</div>
</aside>
</div>
<section aria-label="Next project" className="mx-auto mt-24 max-w-7xl px-4 sm:px-8">
<a href={hrefs?.case ?? "/portfolio/work/lumen"} className="group bg-foreground text-background focus-visible:ring-ring/50 grid items-center gap-8 overflow-hidden rounded-3xl p-6 outline-none focus-visible:ring-[3px] focus-visible:ring-offset-4 focus-visible:ring-offset-background sm:grid-cols-[1fr_20rem] sm:p-10">
<div><p className="text-sm opacity-60" style={mono}>Next project</p><p className="mt-3 text-4xl font-semibold tracking-[-0.04em] text-balance sm:text-6xl">{next.title}</p><p className="mt-5 inline-flex items-center gap-2 font-semibold">{next.client}<ArrowRight className="size-5 transition-transform group-hover:translate-x-1 motion-reduce:transition-none rtl:rotate-180 rtl:group-hover:-translate-x-1" aria-hidden="true" /></p></div>
<Cover variant={next.cover} image={next.image} imageAlt={next.imageAlt} className="rounded-2xl" />
</a>
</section>
</main>
</PortfolioShell>
)
}
export { PortfolioCase, type PortfolioCaseProps }// Ballmac UI: Portfolio writing page. https://ui.ballmac.com/templates/template-portfolio
"use client"
import * as React from "react"
import { Search } from "lucide-react"
import { posts } from "@/components/ballmac/templates/portfolio/portfolio-data"
import { PortfolioShell, type PortfolioHrefs } from "@/components/ballmac/templates/portfolio/portfolio-theme"
import { cn } from "@/lib/utils"
const mono = { fontFamily: "var(--portfolio-mono)" } as const
type Tag = "All" | "Process" | "Craft" | "Career"
const tags: Tag[] = ["All", "Process", "Craft", "Career"]
const dateFmt = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" })
type PortfolioWritingProps = React.ComponentProps<"div"> & { hrefs?: Partial<PortfolioHrefs> }
/** The Portfolio writing page: posts grouped by year, with a topic filter, a search and the reading time of each. */
function PortfolioWriting({ hrefs, ...props }: PortfolioWritingProps) {
const [tag, setTag] = React.useState<Tag>("All")
const [query, setQuery] = React.useState("")
const q = query.trim().toLowerCase()
const shown = posts.filter((p) => (tag === "All" || p.tag === tag) && (!q || (p.title + p.excerpt).toLowerCase().includes(q)))
const years = [...new Set(shown.map((p) => p.date.slice(0, 4)))]
return (
<PortfolioShell page="writing" hrefs={hrefs} {...props}>
<main className="mx-auto max-w-5xl px-4 pt-14 sm:px-8 sm:pt-20">
<p className="text-muted-foreground text-sm" style={mono}>Writing</p>
<h1 className="mt-4 text-[clamp(2.6rem,7vw,5.5rem)] leading-[0.95] font-semibold tracking-[-0.05em] text-balance">Notes on making things people use.</h1>
<div className="mt-10 flex flex-wrap items-center gap-3">
<div role="group" aria-label="Filter by topic" className="flex flex-wrap gap-2">
{tags.map((t) => <button key={t} type="button" aria-pressed={tag === t} onClick={() => setTag(t)} className={cn("focus-visible:ring-ring/50 h-10 rounded-full border px-4 text-sm font-semibold outline-none transition-colors focus-visible:ring-[3px]", tag === t ? "bg-foreground text-background border-transparent" : "hover:bg-accent")}>{t}</button>)}
</div>
<div className="relative w-full sm:ms-auto sm:w-64">
<Search className="text-muted-foreground pointer-events-none absolute top-1/2 start-3.5 size-4 -translate-y-1/2" aria-hidden="true" />
<input type="search" aria-label="Search posts" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search posts" className="bg-card focus-visible:ring-ring/50 placeholder:text-muted-foreground h-10 w-full rounded-full border pe-4 ps-10 text-sm outline-none focus-visible:ring-[3px]" />
</div>
</div>
<p className="sr-only" role="status">{shown.length} posts</p>
{years.length === 0 && <p className="text-muted-foreground mt-16 rounded-3xl border border-dashed p-12 text-center">No posts match. Try another topic.</p>}
{years.map((y) => (
<section key={y} aria-labelledby={`pw-${y}`} className="mt-14 grid gap-4 sm:grid-cols-[8rem_1fr]">
<h2 id={`pw-${y}`} className="text-muted-foreground text-sm" style={mono}>{y}</h2>
<ul className="divide-y border-y">
{shown.filter((p) => p.date.startsWith(y)).map((p) => (
<li key={p.slug}>
<a href="#" className="hover:bg-accent/60 focus-visible:ring-ring/50 -mx-3 block rounded-2xl px-3 py-6 outline-none transition-colors focus-visible:ring-[3px]">
<p className="text-muted-foreground text-xs" style={mono}>{dateFmt.format(new Date(p.date))} · {p.tag} · {p.read} min</p>
<h3 className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-balance">{p.title}</h3>
<p className="text-muted-foreground mt-1.5 text-pretty">{p.excerpt}</p>
</a>
</li>
))}
</ul>
</section>
))}
</main>
</PortfolioShell>
)
}
export { PortfolioWriting, type PortfolioWritingProps }// Ballmac UI: Portfolio uses page. https://ui.ballmac.com/templates/template-portfolio
import * as React from "react"
import { uses } from "@/components/ballmac/templates/portfolio/portfolio-data"
import { PortfolioShell, type PortfolioHrefs } from "@/components/ballmac/templates/portfolio/portfolio-theme"
const mono = { fontFamily: "var(--portfolio-mono)" } as const
type PortfolioUsesProps = React.ComponentProps<"div"> & { hrefs?: Partial<PortfolioHrefs> }
/** The Portfolio uses page: the tools, hardware and books behind the work, in plain lists. */
function PortfolioUses({ hrefs, ...props }: PortfolioUsesProps) {
return (
<PortfolioShell page="uses" hrefs={hrefs} {...props}>
<main className="mx-auto max-w-5xl px-4 pt-14 sm:px-8 sm:pt-20">
<p className="text-muted-foreground text-sm" style={mono}>Uses · updated September 2026</p>
<h1 className="mt-4 text-[clamp(2.6rem,7vw,5.5rem)] leading-[0.95] font-semibold tracking-[-0.05em] text-balance">What’s on my desk, and why.</h1>
<p className="text-muted-foreground mt-6 max-w-xl text-lg text-pretty">People ask. Nothing here is sponsored, and most of it is older than it should be.</p>
<div className="mt-16 space-y-16">
{uses.map((g) => (
<section key={g.group} aria-labelledby={`pu-${g.group}`} className="grid gap-6 sm:grid-cols-[10rem_1fr]">
<h2 id={`pu-${g.group}`} className="text-2xl font-semibold tracking-[-0.03em]">{g.group}</h2>
<ul className="divide-y border-y">
{g.items.map((i) => (
<li key={i.name} className="grid gap-1 py-5 sm:grid-cols-[14rem_1fr] sm:gap-6">
<span className="font-semibold">{i.name}</span>
<span className="text-muted-foreground text-pretty">{i.note}</span>
</li>
))}
</ul>
</section>
))}
</div>
</main>
</PortfolioShell>
)
}
export { PortfolioUses, type PortfolioUsesProps }// Ballmac UI: Portfolio template route. https://ui.ballmac.com/templates/template-portfolio
import type { Metadata } from "next"
import { PortfolioHome } from "@/components/ballmac/templates/portfolio/portfolio-home"
export const metadata: Metadata = {
title: "Ines Calder: product designer",
description: "Independent product designer in Lisbon. Selected work, writing and how to get in touch.",
}
export default function Page() {
return <PortfolioHome />
}// Ballmac UI: Portfolio template route. https://ui.ballmac.com/templates/template-portfolio
import type { Metadata } from "next"
import { PortfolioWork } from "@/components/ballmac/templates/portfolio/portfolio-work"
export const metadata: Metadata = {
title: "Work · Ines Calder",
description: "Six projects and what they changed.",
}
export default function Page() {
return <PortfolioWork />
}// Ballmac UI: Portfolio template route. https://ui.ballmac.com/templates/template-portfolio
import type { Metadata } from "next"
import { PortfolioCase } from "@/components/ballmac/templates/portfolio/portfolio-case"
export const metadata: Metadata = {
title: "Rebuilding onboarding · Ines Calder",
description: "A case study: how a first-run flow got teams to their first invoice in one sitting.",
}
// One layout for every case study. Load the project by params.slug from your data when you add more.
export default function Page() {
return <PortfolioCase />
}// Ballmac UI: Portfolio template route. https://ui.ballmac.com/templates/template-portfolio
import type { Metadata } from "next"
import { PortfolioWriting } from "@/components/ballmac/templates/portfolio/portfolio-writing"
export const metadata: Metadata = {
title: "Writing · Ines Calder",
description: "Notes on making things people use.",
}
export default function Page() {
return <PortfolioWriting />
}// Ballmac UI: Portfolio template route. https://ui.ballmac.com/templates/template-portfolio
import type { Metadata } from "next"
import { PortfolioUses } from "@/components/ballmac/templates/portfolio/portfolio-uses"
export const metadata: Metadata = {
title: "Uses · Ines Calder",
description: "The tools, hardware and books behind the work.",
}
export default function Page() {
return <PortfolioUses />
}Installation
$ pnpm dlx shadcn@latest add @ballmac/template-portfolioInstall the dependencies.
$ pnpm add lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/blur-fade @ballmac/copy-button @ballmac/marquee @ballmac/table-of-contents @ballmac/mediaCopy the source into your project.
components/ballmac/templates/portfolio/portfolio-fonts.ts// Ballmac UI: Portfolio template fonts. https://ui.ballmac.com/templates/template-portfolio import { Bricolage_Grotesque, Geist_Mono } from "next/font/google" export const portfolioSans = Bricolage_Grotesque({ variable: "--portfolio-sans", subsets: ["latin"], axes: ["opsz", "wdth"], display: "swap", preload: false }) export const portfolioMono = Geist_Mono({ variable: "--portfolio-mono", subsets: ["latin"], display: "swap", preload: false })components/ballmac/templates/portfolio/portfolio-data.tsimport type { MediaSource } from "@/components/ballmac/media" // Ballmac UI: Portfolio sample content. https://ui.ballmac.com/templates/template-portfolio export type PortfolioProject = { slug: string title: string client: string year: string discipline: "Product" | "Brand" | "Systems" blurb: string result: string tags: string[] /** Which painted cover to draw (0 to 5). */ cover: number /** Your own image instead of the painted art: an image URL (give it imageAlt), an object with alt text and a dark-mode file, or your own element. */ image?: MediaSource /** Describes `image` when it is a plain URL. */ imageAlt?: string } export const projects: PortfolioProject[] = [ { slug: "fernhill", title: "Rebuilding onboarding", client: "Fernhill", year: "2026", discipline: "Product", blurb: "A first-run flow that gets teams to their first invoice in one sitting.", result: "+34% activation", tags: ["Product design", "Research", "Prototyping"], cover: 0 }, { slug: "lumen", title: "A design system in six weeks", client: "Lumen Health", year: "2025", discipline: "Systems", blurb: "Tokens, 60 components and the docs that made engineers actually use them.", result: "-40% handoff time", tags: ["Design systems", "Documentation"], cover: 1 }, { slug: "paddock", title: "Identity for a riding school", client: "Paddock", year: "2025", discipline: "Brand", blurb: "A mark, a typeface pairing and a handbook for a school that outgrew its logo.", result: "3 offices, 1 look", tags: ["Brand", "Print", "Wayfinding"], cover: 2 }, { slug: "halcyon", title: "A calmer banking app", client: "Halcyon", year: "2024", discipline: "Product", blurb: "Spending insights that read like a sentence, not a spreadsheet.", result: "4.8 App Store rating", tags: ["Mobile", "Data visualisation"], cover: 3 }, { slug: "tessera", title: "Pricing page, tested", client: "Tessera", year: "2024", discipline: "Product", blurb: "Six experiments, one clear winner and a page the sales team stopped apologising for.", result: "+22% trial starts", tags: ["Experimentation", "Copy"], cover: 4 }, { slug: "quanta", title: "Tokens that travel", client: "Quanta", year: "2023", discipline: "Systems", blurb: "A theming layer that let one product ship in nine brands.", result: "9 brands, 1 codebase", tags: ["Design systems", "Tokens"], cover: 5 }, ] export type PortfolioPost = { slug: string; title: string; date: string; read: number; tag: "Process" | "Craft" | "Career"; excerpt: string } export const posts: PortfolioPost[] = [ { slug: "p1", title: "The first-run checklist nobody wants to ship", date: "2026-08-14", read: 7, tag: "Process", excerpt: "Why the thing that tests best is usually the thing that annoys the team." }, { slug: "p2", title: "Stop designing empty states last", date: "2026-05-02", read: 5, tag: "Craft", excerpt: "Empty is the first thing every new user sees. Treat it that way." }, { slug: "p3", title: "What I learned from deleting 40 components", date: "2026-02-19", read: 9, tag: "Process", excerpt: "A design system is a garden. A small, tidy one beats a sprawling one." }, { slug: "p4", title: "Going independent after nine years", date: "2025-11-07", read: 6, tag: "Career", excerpt: "The money, the loneliness and the three clients that made it work." }, { slug: "p5", title: "A defence of the boring button", date: "2025-06-23", read: 4, tag: "Craft", excerpt: "Novelty has a cost. Spend it where it pays." }, { slug: "p6", title: "How I run a design review", date: "2025-03-11", read: 8, tag: "Process", excerpt: "Four questions, one hour and no one defending their pixels." }, ] export const uses: { group: string; items: { name: string; note: string }[] }[] = [ { group: "Hardware", items: [{ name: "MacBook Pro 14″", note: "M3 Pro, 36 GB. Mostly plugged into a desk shelf in Lisbon." }, { name: "Studio Display", note: "The only monitor I have trusted for colour." }, { name: "Keychron Q1", note: "Brown switches. Loud enough that my partner has opinions." }, { name: "iPad Pro + Pencil", note: "Sketches, critique markup and reading." }] }, { group: "Software", items: [{ name: "Figma", note: "Design, prototypes and the occasional diagram." }, { name: "Linear", note: "Where projects and promises go to be counted." }, { name: "Things", note: "Personal tasks. Never work tasks." }, { name: "Raycast", note: "Launcher, clipboard history and window layouts." }] }, { group: "Type", items: [{ name: "Bricolage Grotesque", note: "This site, because I wanted something with a personality." }, { name: "Geist Mono", note: "Small labels and code." }, { name: "Söhne", note: "For client work where budget allows." }] }, { group: "Reading", items: [{ name: "Refactoring UI", note: "Still the best short book on interface design." }, { name: "The Design of Everyday Things", note: "Reread every two years." }, { name: "Field notebooks", note: "Paper, always. Three at a time." }] }, ]components/ballmac/templates/portfolio/portfolio-theme.tsx// Ballmac UI: Portfolio template shell. https://ui.ballmac.com/templates/template-portfolio "use client" import * as React from "react" import { ArrowUpRight, Menu, X } from "lucide-react" import { CopyButton } from "@/components/ballmac/copy-button" import { portfolioMono, portfolioSans } from "@/components/ballmac/templates/portfolio/portfolio-fonts" import { cn } from "@/lib/utils" import { Media, type MediaSource } from "@/components/ballmac/media" type PortfolioPage = "home" | "work" | "case" | "writing" | "uses" type PortfolioHrefs = Record<PortfolioPage, string> const defaultHrefs: PortfolioHrefs = { home: "/portfolio", work: "/portfolio/work", case: "/portfolio/work/fernhill", writing: "/portfolio/writing", uses: "/portfolio/uses" } /** Portfolio's palette: gallery white and ink with one acid lime. In dark mode the lime becomes the light on a black wall. */ const portfolioCss = ` .portfolio-theme,body:not(:has([data-gallery])):has(.portfolio-theme){--background:oklch(0.988 0.003 100);--foreground:oklch(0.16 0.01 100);--card:oklch(1 0 0);--card-foreground:oklch(0.16 0.01 100);--popover:oklch(1 0 0);--popover-foreground:oklch(0.16 0.01 100);--primary:oklch(0.16 0.01 100);--primary-foreground:oklch(0.988 0.003 100);--secondary:oklch(0.958 0.008 100);--secondary-foreground:oklch(0.16 0.01 100);--muted:oklch(0.958 0.008 100);--muted-foreground:oklch(0.46 0.012 100);--accent:oklch(0.94 0.014 110);--accent-foreground:oklch(0.16 0.01 100);--border:oklch(0.16 0.01 100 / 14%);--input:oklch(0.16 0.01 100 / 20%);--ring:oklch(0.16 0.01 100);--surface:oklch(0.968 0.006 100);--destructive:oklch(0.54 0.22 28);--chart-1:oklch(0.9 0.19 125);--chart-2:oklch(0.72 0.17 38);--chart-3:oklch(0.78 0.12 235);--chart-4:oklch(0.76 0.13 300);--chart-5:oklch(0.86 0.09 75);--portfolio-on-accent:oklch(0.16 0.01 100);--radius:1rem} .dark .portfolio-theme,.dark body:not(:has([data-gallery])):has(.portfolio-theme){--background:oklch(0.14 0.004 100);--foreground:oklch(0.96 0.006 100);--card:oklch(0.18 0.005 100);--card-foreground:oklch(0.96 0.006 100);--popover:oklch(0.2 0.006 100);--popover-foreground:oklch(0.96 0.006 100);--primary:oklch(0.96 0.006 100);--primary-foreground:oklch(0.16 0.01 100);--secondary:oklch(0.23 0.006 100);--secondary-foreground:oklch(0.96 0.006 100);--muted:oklch(0.22 0.006 100);--muted-foreground:oklch(0.72 0.012 100);--accent:oklch(0.26 0.01 110);--accent-foreground:oklch(0.96 0.006 100);--border:oklch(1 0 0 / 12%);--input:oklch(1 0 0 / 16%);--ring:oklch(0.9 0.19 125);--surface:oklch(0.165 0.005 100);--destructive:oklch(0.7 0.19 28);--chart-1:oklch(0.9 0.19 125);--chart-2:oklch(0.72 0.17 38);--chart-3:oklch(0.76 0.12 235);--chart-4:oklch(0.74 0.13 300);--chart-5:oklch(0.84 0.09 75);--portfolio-on-accent:oklch(0.16 0.01 100)} body:not(:has([data-gallery])):has(.portfolio-theme){font-family:var(--portfolio-sans),ui-sans-serif,system-ui,sans-serif} ` /** A painted cover: abstract shapes in the chart colours standing in for a screenshot. */ function Cover({ variant, className, image, imageAlt }: { variant: number; className?: string; image?: MediaSource; imageAlt?: string }) { const v = ((variant % 6) + 6) % 6 if (image) return <Media media={image} alt={imageAlt} fill className={cn("aspect-[4/3] w-full", className)} /> return ( <div aria-hidden="true" className={cn("relative isolate aspect-[4/3] w-full overflow-hidden", className)}> {v === 0 && (<><div className="bg-chart-5 absolute inset-0" /><div className="bg-card absolute inset-x-[12%] top-[14%] bottom-0 rounded-t-2xl shadow-xl"><div className="bg-chart-1 m-[7%] h-[14%] w-[40%] rounded-md" /><div className="bg-muted mx-[7%] h-[8%] w-[70%] rounded" /><div className="bg-muted mx-[7%] mt-[3%] h-[8%] w-[54%] rounded" /><div className="mx-[7%] mt-[7%] grid grid-cols-3 gap-[3%]">{[0, 1, 2].map((i) => <div key={i} className="bg-secondary aspect-square rounded-lg" />)}</div></div></>)} {v === 1 && (<><div className="bg-foreground absolute inset-0" /><div className="absolute inset-[10%] grid grid-cols-4 gap-[4%]">{[chart(1), chart(2), chart(3), chart(4), chart(5), chart(1), chart(3), chart(2)].map((c, i) => <div key={i} className={cn("rounded-xl", c)} />)}</div></>)} {v === 2 && (<><div className="bg-chart-2 absolute inset-0" /><div className="absolute top-1/2 left-1/2 aspect-square w-[46%] -translate-x-1/2 -translate-y-1/2"><div className="bg-primary-foreground size-full rounded-full" /><div className="bg-foreground absolute inset-y-0 start-0 w-1/2 rounded-s-full" /></div></>)} {v === 3 && (<><div className="bg-chart-3 absolute inset-0" /><div className="bg-foreground absolute top-[10%] bottom-[-8%] left-1/2 w-[38%] -translate-x-1/2 rounded-[2rem] p-[3%]"><div className="bg-card h-full rounded-[1.4rem] p-[8%]"><div className="bg-chart-1 h-[10%] w-[60%] rounded" /><div className="mt-[10%] flex h-[34%] items-end gap-[6%]">{[40, 70, 55, 90, 65].map((h, i) => <div key={i} className="bg-foreground flex-1 rounded-t" style={{ height: `${h}%` }} />)}</div></div></div></>)} {v === 4 && (<><div className="bg-chart-4 absolute inset-0" /><div className="absolute inset-[12%] grid grid-cols-3 gap-[4%]">{[0, 1, 2].map((i) => <div key={i} className={cn("bg-card rounded-2xl p-[10%]", i === 1 && "bg-foreground -translate-y-[6%]")}><div className={cn("h-[10%] w-[60%] rounded", i === 1 ? "bg-chart-1" : "bg-muted")} /><div className={cn("mt-[18%] h-[22%] w-[50%] rounded", i === 1 ? "bg-primary-foreground" : "bg-secondary")} /></div>)}</div></>)} {v === 5 && (<><div className="bg-secondary absolute inset-0" />{[0, 1, 2, 3, 4].map((i) => <div key={i} className={cn("absolute w-[34%] aspect-square rounded-full mix-blend-multiply", chart((i % 5) + 1))} style={{ left: `${8 + i * 14}%`, top: `${i % 2 ? 40 : 16}%` }} />)}</>)} </div> ) } function chart(n: number) { return ["bg-chart-1", "bg-chart-2", "bg-chart-3", "bg-chart-4", "bg-chart-5"][(n - 1) % 5] } const links: { key: PortfolioPage; label: string }[] = [ { key: "work", label: "Work" }, { key: "writing", label: "Writing" }, { key: "uses", label: "Uses" }, ] type PortfolioShellProps = React.ComponentProps<"div"> & { /** The page being shown, so its nav link is marked current. */ page: PortfolioPage /** Override where pages live (used by previews). */ hrefs?: Partial<PortfolioHrefs> } /** Portfolio's frame: a wordmark with an availability chip, a quiet nav and a huge email in the footer. */ function PortfolioShell({ page, hrefs: overrides, className, style, children, ...props }: PortfolioShellProps) { const hrefs = { ...defaultHrefs, ...overrides } const [open, setOpen] = React.useState(false) React.useEffect(() => { const classes = [portfolioSans.variable, portfolioMono.variable].filter(Boolean) document.body.classList.add(...classes) return () => document.body.classList.remove(...classes) }, []) const active = page === "case" ? "work" : page return ( <div data-slot="portfolio" className={cn("portfolio-theme bg-background text-foreground relative min-h-dvh overflow-x-clip", portfolioSans.variable, portfolioMono.variable, className)} style={{ fontFamily: "var(--portfolio-sans), ui-sans-serif, system-ui, sans-serif", ...style }} {...props} > <style>{portfolioCss}</style> <header className="bg-background/80 sticky top-0 z-40 backdrop-blur-xl"> <div className="mx-auto flex h-16 max-w-7xl items-center justify-between px-4 sm:px-8"> <a href={hrefs.home} className="focus-visible:ring-ring/50 flex items-center gap-3 rounded-lg outline-none focus-visible:ring-[3px]"> <span className="bg-chart-1 text-[var(--portfolio-on-accent)] flex size-9 items-center justify-center rounded-full text-sm font-bold tracking-tight" aria-hidden="true">IC</span> <span className="font-semibold tracking-tight">Ines Calder</span> </a> <span className="text-muted-foreground hidden items-center gap-2 text-xs lg:flex" style={{ fontFamily: "var(--portfolio-mono)" }}> <span className="relative flex size-2"><span className="bg-chart-2 absolute inset-0 rounded-full" /><span className="bg-chart-2 absolute inset-0 rounded-full opacity-60 motion-safe:animate-ping" /></span> Booking Q1 2027 · Lisbon </span> <nav aria-label="Main" className="hidden items-center gap-1 md:flex"> {links.map((l) => ( <a key={l.key} href={hrefs[l.key]} aria-current={active === l.key ? "page" : undefined} className="text-muted-foreground hover:text-foreground aria-[current=page]:text-foreground aria-[current=page]:bg-accent focus-visible:ring-ring/50 rounded-full px-4 py-2 text-sm font-medium outline-none transition-colors focus-visible:ring-[3px]"> {l.label} </a> ))} <a href="#contact" className="bg-foreground text-background focus-visible:ring-ring/50 ms-2 inline-flex h-10 items-center gap-1.5 rounded-full px-5 text-sm font-semibold outline-none transition-opacity hover:opacity-90 focus-visible:ring-[3px]">Say hello <ArrowUpRight className="size-4 rtl:-scale-x-100" aria-hidden="true" /></a> </nav> <button type="button" aria-label={open ? "Close menu" : "Open menu"} aria-expanded={open} aria-controls="portfolio-mobile-menu" onClick={() => setOpen((v) => !v)} className="hover:bg-accent focus-visible:ring-ring/50 inline-flex size-10 items-center justify-center rounded-full outline-none focus-visible:ring-[3px] md:hidden"> {open ? <X className="size-5" aria-hidden="true" /> : <Menu className="size-5" aria-hidden="true" />} </button> </div> {open && ( <nav id="portfolio-mobile-menu" aria-label="Mobile" className="px-4 pb-4 md:hidden"> {[{ key: "home" as const, label: "Home" }, ...links].map((l) => <a key={l.key} href={hrefs[l.key]} className="hover:bg-accent block rounded-2xl px-4 py-3 text-lg font-medium">{l.label}</a>)} </nav> )} </header> {children} <footer id="contact" className="mt-24 border-t"> <div className="mx-auto max-w-7xl px-4 pt-16 pb-10 sm:px-8"> <p className="text-muted-foreground text-sm" style={{ fontFamily: "var(--portfolio-mono)" }}>Have a product that needs care?</p> <div className="mt-4 flex flex-wrap items-center gap-3"> <a href="mailto:hello@inescalder.example" className="focus-visible:ring-ring/50 rounded-xl text-[clamp(2rem,7.4vw,6.5rem)] leading-none font-semibold tracking-[-0.045em] break-all outline-none hover:underline hover:decoration-[0.08em] hover:underline-offset-[0.12em] focus-visible:ring-[3px]">hello@inescalder.example</a> <CopyButton value="hello@inescalder.example" ariaLabel="Copy email address" className="size-11" /> </div> <div className="text-muted-foreground mt-14 flex flex-wrap items-center justify-between gap-4 border-t pt-6 text-sm"> <p>© 2026 Ines Calder. Designed and built in Lisbon.</p> <ul className="flex gap-5">{["LinkedIn", "Read.cv", "Are.na", "RSS"].map((l) => <li key={l}><a href="#" className="hover:text-foreground transition-colors">{l}</a></li>)}</ul> </div> </div> </footer> </div> ) } export { Cover, PortfolioShell, defaultHrefs as portfolioDefaultHrefs, type PortfolioHrefs, type PortfolioPage, type PortfolioShellProps }components/ballmac/templates/portfolio/portfolio-home.tsx// Ballmac UI: Portfolio home page. https://ui.ballmac.com/templates/template-portfolio "use client" import * as React from "react" import { ArrowDownRight, ArrowUpRight, BookOpen, Headphones, Hammer, MapPin } from "lucide-react" import { BlurFade } from "@/components/ballmac/blur-fade" import { Marquee } from "@/components/ballmac/marquee" import { posts, projects } from "@/components/ballmac/templates/portfolio/portfolio-data" import { Cover, PortfolioShell, type PortfolioHrefs } from "@/components/ballmac/templates/portfolio/portfolio-theme" import { cn } from "@/lib/utils" const mono = { fontFamily: "var(--portfolio-mono)" } as const const quotes = [ ["Ines made the hard thing feel obvious. Our activation rate said so.", "Sasha Petrova, Fernhill"], ["The calmest designer I have ever worked with, and the sharpest.", "Marcus Lindqvist, Halcyon"], ["She cut our component library in half and everyone thanked her.", "Priya Raman, Lumen Health"], ["We hired Ines for a logo and got a company worth working for.", "Tom Beltrán, Paddock"], ] function Pill({ className, children }: { className?: string; children?: React.ReactNode }) { return <span aria-hidden="true" className={cn("mx-1 inline-block h-[0.72em] w-[1.6em] rounded-full align-[-0.06em] sm:mx-2", className)}>{children}</span> } type PortfolioHomeProps = React.ComponentProps<"div"> & { hrefs?: Partial<PortfolioHrefs> } /** The Portfolio home page: a bold intro, a work grid whose cards reveal a result on hover and focus, an about bento, kind words and recent writing. */ function PortfolioHome({ hrefs, ...props }: PortfolioHomeProps) { const h = { work: hrefs?.work ?? "/portfolio/work", case: hrefs?.case ?? "/portfolio/work/fernhill", writing: hrefs?.writing ?? "/portfolio/writing" } return ( <PortfolioShell page="home" hrefs={hrefs} {...props}> <main> <section className="mx-auto max-w-7xl px-4 pt-14 pb-10 sm:px-8 sm:pt-24"> <BlurFade> <p className="text-muted-foreground text-sm" style={mono}>Independent product designer · 9 years · Lisbon</p> <h1 className="mt-6 max-w-6xl text-[clamp(2.6rem,8.6vw,8rem)] leading-[0.94] font-semibold tracking-[-0.05em] text-balance"> I design products people <Pill className="bg-chart-1" />finish <Pill className="bg-chart-2" />using, and teams <Pill className="bg-chart-3" />enjoy shipping. </h1> <div className="mt-10 flex flex-wrap items-center gap-3"> <a href="#work" className="bg-foreground text-background focus-visible:ring-ring/50 inline-flex h-12 items-center gap-2 rounded-full px-6 font-semibold outline-none transition-opacity hover:opacity-90 focus-visible:ring-[3px]">Selected work <ArrowDownRight className="size-4 rtl:-scale-x-100" aria-hidden="true" /></a> <a href={h.writing} className="hover:bg-accent focus-visible:ring-ring/50 inline-flex h-12 items-center rounded-full border px-6 font-semibold outline-none transition-colors focus-visible:ring-[3px]">Read my writing</a> </div> </BlurFade> </section> <section aria-label="Skills" className="border-y py-5"> <Marquee speed={40} gap={40} pauseOnHover> {["Product design", "Design systems", "Prototyping", "Research", "Brand", "Workshops", "Art direction", "Front-end"].map((s) => <span key={s} className="text-muted-foreground flex items-center gap-10 text-2xl font-medium tracking-tight sm:text-3xl">{s}<span className="bg-chart-1 size-2.5 rounded-full" aria-hidden="true" /></span>)} </Marquee> </section> <section id="work" aria-labelledby="pf-work" className="mx-auto max-w-7xl scroll-mt-20 px-4 py-20 sm:px-8"> <div className="flex items-end justify-between gap-4"> <h2 id="pf-work" className="text-4xl font-semibold tracking-[-0.04em] sm:text-6xl">Selected work</h2> <a href={h.work} className="focus-visible:ring-ring/50 inline-flex items-center gap-1.5 rounded-full text-sm font-semibold underline-offset-4 outline-none hover:underline focus-visible:ring-[3px]">All projects <ArrowUpRight className="size-4 rtl:-scale-x-100" aria-hidden="true" /></a> </div> <ul className="mt-10 grid gap-x-6 gap-y-12 sm:grid-cols-2"> {projects.slice(0, 4).map((p, i) => ( <li key={p.slug} className={cn(i % 2 === 1 && "sm:mt-16")}> <a href={h.case} className="group focus-visible:ring-ring/50 block rounded-3xl outline-none focus-visible:ring-[3px] focus-visible:ring-offset-4 focus-visible:ring-offset-background"> <div className="relative overflow-hidden rounded-3xl border"> <Cover variant={p.cover} image={p.image} imageAlt={p.imageAlt} className="transition-transform duration-700 ease-out group-hover:scale-[1.04] group-focus-visible:scale-[1.04] motion-reduce:transition-none" /> <div className="bg-chart-1 text-[var(--portfolio-on-accent)] absolute end-4 bottom-4 flex translate-y-3 items-center gap-2 rounded-full px-4 py-2 text-sm font-bold opacity-0 shadow-lg transition-all duration-300 group-hover:translate-y-0 group-hover:opacity-100 group-focus-visible:translate-y-0 group-focus-visible:opacity-100 motion-reduce:transition-none"> {p.result} <ArrowUpRight className="size-4 rtl:-scale-x-100" aria-hidden="true" /> </div> </div> <div className="mt-5 flex items-start justify-between gap-4"> <div> <h3 className="text-2xl font-semibold tracking-[-0.03em] text-balance">{p.title}</h3> <p className="text-muted-foreground mt-1 text-pretty">{p.blurb}</p> </div> <p className="text-muted-foreground shrink-0 text-sm" style={mono}>{p.client}<br />{p.year}</p> </div> <p className="sr-only">Result: {p.result}</p> </a> </li> ))} </ul> </section> <section aria-labelledby="pf-about" className="mx-auto max-w-7xl px-4 pb-20 sm:px-8"> <h2 id="pf-about" className="sr-only">About</h2> <div className="grid gap-4 md:grid-cols-4 md:grid-rows-2"> <div className="bg-chart-1 text-[var(--portfolio-on-accent)] relative flex min-h-72 flex-col justify-end overflow-hidden rounded-3xl p-7 md:col-span-2 md:row-span-2"> <div aria-hidden="true" className="absolute -top-8 -end-8 size-56 rounded-full border-[28px] border-current opacity-15" /> <div aria-hidden="true" className="absolute top-6 start-6 flex size-24 items-center justify-center rounded-full bg-primary text-4xl font-semibold text-background">IC</div> <p className="text-3xl leading-tight font-semibold tracking-[-0.03em] text-balance sm:text-4xl">Hi, I’m Ines. I turn tangled products into ones that feel obvious.</p> <p className="mt-3 max-w-md text-pretty opacity-80">Nine years across fintech, health and developer tools. Before going independent I led design at Fernhill and Lumen Health.</p> </div> <div className="bg-card rounded-3xl border p-6"> <p className="text-muted-foreground flex items-center gap-2 text-xs" style={mono}><MapPin className="size-3.5" aria-hidden="true" />Based in</p> <p className="mt-3 text-3xl font-semibold tracking-[-0.03em]">Lisbon</p> <p className="text-muted-foreground mt-1 text-sm">Working across Europe and the US East Coast.</p> </div> <div className="bg-foreground text-background rounded-3xl p-6"> <dl className="space-y-5"> <div><dd className="text-5xl font-semibold tracking-[-0.04em] tabular-nums">9</dd><dt className="text-sm opacity-75">years designing</dt></div> <div><dd className="text-5xl font-semibold tracking-[-0.04em] tabular-nums">40+</dd><dt className="text-sm opacity-75">products launched</dt></div> </dl> </div> <div className="bg-card rounded-3xl border p-6 md:col-span-2"> <p className="text-muted-foreground text-xs" style={mono}>Right now</p> <ul className="mt-4 grid gap-4 sm:grid-cols-3"> {[[Hammer, "Building", "A pricing experiment kit for Tessera"], [BookOpen, "Reading", "The Timeless Way of Building"], [Headphones, "Listening", "Fela Kuti, on repeat"]].map(([Icon, k, v]) => { const I = Icon as typeof Hammer return <li key={k as string}><I className="size-5" aria-hidden="true" /><p className="mt-3 text-sm font-semibold">{k as string}</p><p className="text-muted-foreground text-sm text-pretty">{v as string}</p></li> })} </ul> </div> </div> </section> <section aria-label="Kind words" className="border-y py-10"> <Marquee speed={32} gap={48} reverse pauseOnHover> {quotes.map(([q, who]) => ( <figure key={who} className="w-[26rem] max-w-[80vw]"> <blockquote className="text-xl leading-snug font-medium tracking-[-0.02em] text-balance">“{q}”</blockquote> <figcaption className="text-muted-foreground mt-3 text-sm" style={mono}>{who}</figcaption> </figure> ))} </Marquee> </section> <section aria-labelledby="pf-writing" className="mx-auto max-w-7xl px-4 pt-20 sm:px-8"> <div className="flex items-end justify-between gap-4"> <h2 id="pf-writing" className="text-4xl font-semibold tracking-[-0.04em] sm:text-5xl">Recent writing</h2> <a href={h.writing} className="focus-visible:ring-ring/50 inline-flex items-center gap-1.5 rounded-full text-sm font-semibold underline-offset-4 outline-none hover:underline focus-visible:ring-[3px]">All posts <ArrowUpRight className="size-4 rtl:-scale-x-100" aria-hidden="true" /></a> </div> <ul className="mt-8 divide-y border-y"> {posts.slice(0, 3).map((p) => ( <li key={p.slug}> <a href={h.writing} className="hover:bg-accent/60 focus-visible:ring-ring/50 group -mx-3 grid grid-cols-[1fr_auto] items-baseline gap-4 rounded-2xl px-3 py-5 outline-none transition-colors focus-visible:ring-[3px] sm:grid-cols-[1fr_8rem_6rem]"> <span className="text-xl font-semibold tracking-[-0.02em] text-balance sm:text-2xl">{p.title}</span> <span className="text-muted-foreground hidden text-sm sm:block" style={mono}>{p.tag}</span> <span className="text-muted-foreground text-end text-sm tabular-nums" style={mono}>{p.read} min</span> </a> </li> ))} </ul> </section> </main> </PortfolioShell> ) } export { PortfolioHome, type PortfolioHomeProps }components/ballmac/templates/portfolio/portfolio-work.tsx// Ballmac UI: Portfolio work page. https://ui.ballmac.com/templates/template-portfolio "use client" import * as React from "react" import { ArrowUpRight } from "lucide-react" import { projects } from "@/components/ballmac/templates/portfolio/portfolio-data" import { Cover, PortfolioShell, type PortfolioHrefs } from "@/components/ballmac/templates/portfolio/portfolio-theme" import { cn } from "@/lib/utils" type Filter = "All" | "Product" | "Brand" | "Systems" const filters: Filter[] = ["All", "Product", "Systems", "Brand"] type PortfolioWorkProps = React.ComponentProps<"div"> & { hrefs?: Partial<PortfolioHrefs> } /** The Portfolio work page: every project, filtered by discipline, in a staggered grid with a result on hover. */ function PortfolioWork({ hrefs, ...props }: PortfolioWorkProps) { const [filter, setFilter] = React.useState<Filter>("All") const shown = projects.filter((p) => filter === "All" || p.discipline === filter) const caseHref = hrefs?.case ?? "/portfolio/work/fernhill" return ( <PortfolioShell page="work" hrefs={hrefs} {...props}> <main className="mx-auto max-w-7xl px-4 pt-14 sm:px-8 sm:pt-20"> <p className="text-muted-foreground text-sm" style={{ fontFamily: "var(--portfolio-mono)" }}>Work · 2023 to 2026</p> <h1 className="mt-4 max-w-4xl text-[clamp(2.6rem,7vw,6rem)] leading-[0.95] font-semibold tracking-[-0.05em] text-balance">Six projects I’m proud of, and what they changed.</h1> <div role="group" aria-label="Filter by discipline" className="mt-10 flex flex-wrap gap-2"> {filters.map((f) => { const n = f === "All" ? projects.length : projects.filter((p) => p.discipline === f).length return ( <button key={f} type="button" aria-pressed={filter === f} onClick={() => setFilter(f)} className={cn("focus-visible:ring-ring/50 h-11 rounded-full border px-5 text-sm font-semibold outline-none transition-colors focus-visible:ring-[3px]", filter === f ? "bg-foreground text-background border-transparent" : "hover:bg-accent")}> {f} <span className={cn("ms-1 tabular-nums", filter === f ? "opacity-70" : "text-muted-foreground")}>{n}</span> </button> ) })} </div> <p className="sr-only" role="status">{shown.length} projects shown</p> <ul className="mt-12 grid gap-x-6 gap-y-14 sm:grid-cols-2 lg:grid-cols-3"> {shown.map((p) => ( <li key={p.slug}> <a href={caseHref} className="group focus-visible:ring-ring/50 block rounded-3xl outline-none focus-visible:ring-[3px] focus-visible:ring-offset-4 focus-visible:ring-offset-background"> <div className="relative overflow-hidden rounded-3xl border"> <Cover variant={p.cover} image={p.image} imageAlt={p.imageAlt} className="transition-transform duration-700 group-hover:scale-[1.04] group-focus-visible:scale-[1.04] motion-reduce:transition-none" /> <span className="bg-chart-1 text-[var(--portfolio-on-accent)] absolute end-3 bottom-3 inline-flex translate-y-2 items-center gap-1.5 rounded-full px-3.5 py-1.5 text-xs font-bold opacity-0 transition-all duration-300 group-hover:translate-y-0 group-hover:opacity-100 group-focus-visible:translate-y-0 group-focus-visible:opacity-100 motion-reduce:transition-none">{p.result}<ArrowUpRight className="size-3.5 rtl:-scale-x-100" aria-hidden="true" /></span> </div> <div className="mt-4"> <p className="text-muted-foreground text-xs" style={{ fontFamily: "var(--portfolio-mono)" }}>{p.client} · {p.year} · {p.discipline}</p> <h2 className="mt-1.5 text-xl font-semibold tracking-[-0.025em] text-balance">{p.title}</h2> <p className="text-muted-foreground mt-1 text-sm text-pretty">{p.blurb}</p> <p className="sr-only">Result: {p.result}</p> </div> </a> </li> ))} </ul> </main> </PortfolioShell> ) } export { PortfolioWork, type PortfolioWorkProps }components/ballmac/templates/portfolio/portfolio-case.tsx// Ballmac UI: Portfolio case study page. https://ui.ballmac.com/templates/template-portfolio "use client" import * as React from "react" import { ArrowRight } from "lucide-react" import { TableOfContents } from "@/components/ballmac/table-of-contents" import { projects } from "@/components/ballmac/templates/portfolio/portfolio-data" import { Cover, PortfolioShell, type PortfolioHrefs } from "@/components/ballmac/templates/portfolio/portfolio-theme" import { cn } from "@/lib/utils" const mono = { fontFamily: "var(--portfolio-mono)" } as const const h2 = "scroll-mt-24 text-3xl font-semibold tracking-[-0.035em] text-balance sm:text-4xl" const body = "text-muted-foreground mt-5 max-w-2xl text-lg leading-relaxed text-pretty" const meta = [ ["Role", "Lead product designer"], ["Team", "2 designers, 5 engineers, 1 PM"], ["Timeline", "14 weeks, Mar to Jun 2026"], ["Tools", "Figma, Linear, Maze, Framer"], ] const process = [ ["Week 1–2", "Listen", "Watched 14 new teams try to get started. Nine gave up before inviting a colleague."], ["Week 3–5", "Frame", "Reduced the goal to one sentence: send your first invoice in one sitting."], ["Week 6–10", "Build and test", "Four prototypes, 22 tests. We cut the setup from nine steps to three."], ["Week 11–14", "Ship and measure", "Rolled out behind a flag to 10, then 50, then 100 percent of new signups."], ] const results = [["+34%", "of new teams activate in their first week"], ["−58%", "time to first invoice"], ["9 → 3", "setup steps"], ["4.7", "onboarding satisfaction, up from 3.9"]] type PortfolioCaseProps = React.ComponentProps<"div"> & { hrefs?: Partial<PortfolioHrefs> } /** The Portfolio case study: a sticky fact sheet, a contents list that follows your reading, the process as a timeline and results in big numbers. */ function PortfolioCase({ hrefs, ...props }: PortfolioCaseProps) { const next = projects[1] return ( <PortfolioShell page="case" hrefs={hrefs} {...props}> <main> <header className="mx-auto max-w-7xl px-4 pt-14 sm:px-8 sm:pt-20"> <p className="text-muted-foreground text-sm" style={mono}><a href={hrefs?.work ?? "/portfolio/work"} className="underline-offset-4 hover:underline">Work</a> / Fernhill</p> <h1 className="mt-4 max-w-5xl text-[clamp(2.6rem,7.4vw,6.5rem)] leading-[0.95] font-semibold tracking-[-0.05em] text-balance">Rebuilding onboarding so teams reach their first invoice in one sitting.</h1> <Cover variant={0} className="mt-12 aspect-[16/8] rounded-3xl border" /> </header> <div className="mx-auto mt-16 grid max-w-7xl gap-12 px-4 sm:px-8 lg:grid-cols-[16rem_minmax(0,1fr)] xl:grid-cols-[16rem_minmax(0,1fr)_14rem]"> <aside className="lg:sticky lg:top-24 lg:self-start"> <dl className="space-y-5"> {meta.map(([k, v]) => <div key={k}><dt className="text-muted-foreground text-xs" style={mono}>{k}</dt><dd className="mt-1 text-sm font-medium text-pretty">{v}</dd></div>)} </dl> </aside> <article className="min-w-0 space-y-20"> <section aria-labelledby="overview"> <h2 id="overview" className={h2}>Overview</h2> <p className={body}>Fernhill is invoicing software for small studios. It was good at the second month and bad at the first day: almost half of new teams never sent an invoice. I led a 14-week rebuild of the first-run experience.</p> </section> <section aria-labelledby="problem"> <h2 id="problem" className={h2}>The problem</h2> <p className={body}>The old flow asked for nine things before it showed any value: company details, tax settings, a logo, bank info, a template, a first customer. Every step was reasonable. Together they were a wall.</p> <blockquote className="bg-chart-1 text-[var(--portfolio-on-accent)] mt-8 max-w-2xl rounded-3xl p-8 text-2xl leading-snug font-semibold tracking-[-0.025em] text-balance">“I just wanted to send one invoice. I closed the tab at step six.”<footer className="mt-4 text-sm font-medium opacity-75" style={mono}>Participant 7, studio owner</footer></blockquote> </section> <section aria-labelledby="process"> <h2 id="process" className={h2}>Process</h2> <ol className="mt-8 divide-y border-y"> {process.map(([when, title, text]) => ( <li key={title} className="grid gap-2 py-6 sm:grid-cols-[8rem_10rem_1fr] sm:gap-6"> <span className="text-muted-foreground text-sm" style={mono}>{when}</span> <span className="text-xl font-semibold tracking-[-0.02em]">{title}</span> <span className="text-muted-foreground text-pretty">{text}</span> </li> ))} </ol> </section> <section aria-labelledby="solution"> <h2 id="solution" className={h2}>The solution</h2> <p className={body}>One screen asks the only question that matters: who are you invoicing? Everything else is a default you can change later, and the first invoice is a draft by the time you finish typing the customer’s name.</p> <div className="mt-8 grid gap-4 sm:grid-cols-2"><Cover variant={4} className="rounded-3xl border" /><Cover variant={3} className="rounded-3xl border" /></div> </section> <section aria-labelledby="results"> <h2 id="results" className={h2}>Results</h2> <dl className="mt-8 grid gap-4 sm:grid-cols-2"> {results.map(([v, l], i) => ( <div key={l} className={cn("rounded-3xl p-7", i === 0 ? "bg-chart-1 text-[var(--portfolio-on-accent)]" : "bg-secondary")}> <dd className="text-6xl font-semibold tracking-[-0.05em] tabular-nums">{v}</dd> <dt className={cn("mt-2 text-pretty", i === 0 ? "opacity-80" : "text-muted-foreground")}>{l}</dt> </div> ))} </dl> </section> <section aria-labelledby="reflection"> <h2 id="reflection" className={h2}>What I’d do differently</h2> <p className={body}>I would have involved sales earlier. They knew which questions prospects asked on day one, and two of them became default answers we had to bolt on after launch.</p> </section> </article> <aside className="hidden xl:block"> <div className="sticky top-24"> <TableOfContents title="On this page" offset={96} items={[{ id: "overview", title: "Overview" }, { id: "problem", title: "The problem" }, { id: "process", title: "Process" }, { id: "solution", title: "The solution" }, { id: "results", title: "Results" }, { id: "reflection", title: "Reflection" }]} /> </div> </aside> </div> <section aria-label="Next project" className="mx-auto mt-24 max-w-7xl px-4 sm:px-8"> <a href={hrefs?.case ?? "/portfolio/work/lumen"} className="group bg-foreground text-background focus-visible:ring-ring/50 grid items-center gap-8 overflow-hidden rounded-3xl p-6 outline-none focus-visible:ring-[3px] focus-visible:ring-offset-4 focus-visible:ring-offset-background sm:grid-cols-[1fr_20rem] sm:p-10"> <div><p className="text-sm opacity-60" style={mono}>Next project</p><p className="mt-3 text-4xl font-semibold tracking-[-0.04em] text-balance sm:text-6xl">{next.title}</p><p className="mt-5 inline-flex items-center gap-2 font-semibold">{next.client}<ArrowRight className="size-5 transition-transform group-hover:translate-x-1 motion-reduce:transition-none rtl:rotate-180 rtl:group-hover:-translate-x-1" aria-hidden="true" /></p></div> <Cover variant={next.cover} image={next.image} imageAlt={next.imageAlt} className="rounded-2xl" /> </a> </section> </main> </PortfolioShell> ) } export { PortfolioCase, type PortfolioCaseProps }components/ballmac/templates/portfolio/portfolio-writing.tsx// Ballmac UI: Portfolio writing page. https://ui.ballmac.com/templates/template-portfolio "use client" import * as React from "react" import { Search } from "lucide-react" import { posts } from "@/components/ballmac/templates/portfolio/portfolio-data" import { PortfolioShell, type PortfolioHrefs } from "@/components/ballmac/templates/portfolio/portfolio-theme" import { cn } from "@/lib/utils" const mono = { fontFamily: "var(--portfolio-mono)" } as const type Tag = "All" | "Process" | "Craft" | "Career" const tags: Tag[] = ["All", "Process", "Craft", "Career"] const dateFmt = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" }) type PortfolioWritingProps = React.ComponentProps<"div"> & { hrefs?: Partial<PortfolioHrefs> } /** The Portfolio writing page: posts grouped by year, with a topic filter, a search and the reading time of each. */ function PortfolioWriting({ hrefs, ...props }: PortfolioWritingProps) { const [tag, setTag] = React.useState<Tag>("All") const [query, setQuery] = React.useState("") const q = query.trim().toLowerCase() const shown = posts.filter((p) => (tag === "All" || p.tag === tag) && (!q || (p.title + p.excerpt).toLowerCase().includes(q))) const years = [...new Set(shown.map((p) => p.date.slice(0, 4)))] return ( <PortfolioShell page="writing" hrefs={hrefs} {...props}> <main className="mx-auto max-w-5xl px-4 pt-14 sm:px-8 sm:pt-20"> <p className="text-muted-foreground text-sm" style={mono}>Writing</p> <h1 className="mt-4 text-[clamp(2.6rem,7vw,5.5rem)] leading-[0.95] font-semibold tracking-[-0.05em] text-balance">Notes on making things people use.</h1> <div className="mt-10 flex flex-wrap items-center gap-3"> <div role="group" aria-label="Filter by topic" className="flex flex-wrap gap-2"> {tags.map((t) => <button key={t} type="button" aria-pressed={tag === t} onClick={() => setTag(t)} className={cn("focus-visible:ring-ring/50 h-10 rounded-full border px-4 text-sm font-semibold outline-none transition-colors focus-visible:ring-[3px]", tag === t ? "bg-foreground text-background border-transparent" : "hover:bg-accent")}>{t}</button>)} </div> <div className="relative w-full sm:ms-auto sm:w-64"> <Search className="text-muted-foreground pointer-events-none absolute top-1/2 start-3.5 size-4 -translate-y-1/2" aria-hidden="true" /> <input type="search" aria-label="Search posts" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search posts" className="bg-card focus-visible:ring-ring/50 placeholder:text-muted-foreground h-10 w-full rounded-full border pe-4 ps-10 text-sm outline-none focus-visible:ring-[3px]" /> </div> </div> <p className="sr-only" role="status">{shown.length} posts</p> {years.length === 0 && <p className="text-muted-foreground mt-16 rounded-3xl border border-dashed p-12 text-center">No posts match. Try another topic.</p>} {years.map((y) => ( <section key={y} aria-labelledby={`pw-${y}`} className="mt-14 grid gap-4 sm:grid-cols-[8rem_1fr]"> <h2 id={`pw-${y}`} className="text-muted-foreground text-sm" style={mono}>{y}</h2> <ul className="divide-y border-y"> {shown.filter((p) => p.date.startsWith(y)).map((p) => ( <li key={p.slug}> <a href="#" className="hover:bg-accent/60 focus-visible:ring-ring/50 -mx-3 block rounded-2xl px-3 py-6 outline-none transition-colors focus-visible:ring-[3px]"> <p className="text-muted-foreground text-xs" style={mono}>{dateFmt.format(new Date(p.date))} · {p.tag} · {p.read} min</p> <h3 className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-balance">{p.title}</h3> <p className="text-muted-foreground mt-1.5 text-pretty">{p.excerpt}</p> </a> </li> ))} </ul> </section> ))} </main> </PortfolioShell> ) } export { PortfolioWriting, type PortfolioWritingProps }components/ballmac/templates/portfolio/portfolio-uses.tsx// Ballmac UI: Portfolio uses page. https://ui.ballmac.com/templates/template-portfolio import * as React from "react" import { uses } from "@/components/ballmac/templates/portfolio/portfolio-data" import { PortfolioShell, type PortfolioHrefs } from "@/components/ballmac/templates/portfolio/portfolio-theme" const mono = { fontFamily: "var(--portfolio-mono)" } as const type PortfolioUsesProps = React.ComponentProps<"div"> & { hrefs?: Partial<PortfolioHrefs> } /** The Portfolio uses page: the tools, hardware and books behind the work, in plain lists. */ function PortfolioUses({ hrefs, ...props }: PortfolioUsesProps) { return ( <PortfolioShell page="uses" hrefs={hrefs} {...props}> <main className="mx-auto max-w-5xl px-4 pt-14 sm:px-8 sm:pt-20"> <p className="text-muted-foreground text-sm" style={mono}>Uses · updated September 2026</p> <h1 className="mt-4 text-[clamp(2.6rem,7vw,5.5rem)] leading-[0.95] font-semibold tracking-[-0.05em] text-balance">What’s on my desk, and why.</h1> <p className="text-muted-foreground mt-6 max-w-xl text-lg text-pretty">People ask. Nothing here is sponsored, and most of it is older than it should be.</p> <div className="mt-16 space-y-16"> {uses.map((g) => ( <section key={g.group} aria-labelledby={`pu-${g.group}`} className="grid gap-6 sm:grid-cols-[10rem_1fr]"> <h2 id={`pu-${g.group}`} className="text-2xl font-semibold tracking-[-0.03em]">{g.group}</h2> <ul className="divide-y border-y"> {g.items.map((i) => ( <li key={i.name} className="grid gap-1 py-5 sm:grid-cols-[14rem_1fr] sm:gap-6"> <span className="font-semibold">{i.name}</span> <span className="text-muted-foreground text-pretty">{i.note}</span> </li> ))} </ul> </section> ))} </div> </main> </PortfolioShell> ) } export { PortfolioUses, type PortfolioUsesProps }app/portfolio/page.tsx// Ballmac UI: Portfolio template route. https://ui.ballmac.com/templates/template-portfolio import type { Metadata } from "next" import { PortfolioHome } from "@/components/ballmac/templates/portfolio/portfolio-home" export const metadata: Metadata = { title: "Ines Calder: product designer", description: "Independent product designer in Lisbon. Selected work, writing and how to get in touch.", } export default function Page() { return <PortfolioHome /> }app/portfolio/work/page.tsx// Ballmac UI: Portfolio template route. https://ui.ballmac.com/templates/template-portfolio import type { Metadata } from "next" import { PortfolioWork } from "@/components/ballmac/templates/portfolio/portfolio-work" export const metadata: Metadata = { title: "Work · Ines Calder", description: "Six projects and what they changed.", } export default function Page() { return <PortfolioWork /> }app/portfolio/work/[slug]/page.tsx// Ballmac UI: Portfolio template route. https://ui.ballmac.com/templates/template-portfolio import type { Metadata } from "next" import { PortfolioCase } from "@/components/ballmac/templates/portfolio/portfolio-case" export const metadata: Metadata = { title: "Rebuilding onboarding · Ines Calder", description: "A case study: how a first-run flow got teams to their first invoice in one sitting.", } // One layout for every case study. Load the project by params.slug from your data when you add more. export default function Page() { return <PortfolioCase /> }app/portfolio/writing/page.tsx// Ballmac UI: Portfolio template route. https://ui.ballmac.com/templates/template-portfolio import type { Metadata } from "next" import { PortfolioWriting } from "@/components/ballmac/templates/portfolio/portfolio-writing" export const metadata: Metadata = { title: "Writing · Ines Calder", description: "Notes on making things people use.", } export default function Page() { return <PortfolioWriting /> }app/portfolio/uses/page.tsx// Ballmac UI: Portfolio template route. https://ui.ballmac.com/templates/template-portfolio import type { Metadata } from "next" import { PortfolioUses } from "@/components/ballmac/templates/portfolio/portfolio-uses" export const metadata: Metadata = { title: "Uses · Ines Calder", description: "The tools, hardware and books behind the work.", } export default function Page() { return <PortfolioUses /> }Update the import paths to match your project setup.
The page is added as a route in your app, with every block it uses.
What's inside
- Pages
- Home/portfolio
- Work/portfolio/work
- Case study/portfolio/work/fernhill
- Writing/portfolio/writing
- Uses/portfolio/uses
- Fonts
- Bricolage Grotesque, Geist Mono (Google Fonts, loaded with next/font)
- Files
- 13 files · 5 Ballmac blocks and components
Built with
Dependencies
- npm
- lucide-react
Use with AI
Installs a five-page personal portfolio. Change the content in portfolio-data.ts, the palette in portfolioCss, and the painted covers in the Cover component (or replace them with images). With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Folio: designer portfolio (@ballmac/template-portfolio) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Personal sites for designers, developers and independent consultants
- A bold, typographic portfolio with a real case-study layout
Not for
- Agencies with a team and services pages (use template-studio)
Registry JSON: https://ui.ballmac.com/r/template-portfolio.json
Free to use in personal and commercial projects.