Studio: creative agency
A loud, typographic five-page agency site: giant wide headlines, a work reel whose poster follows the pointer, numbered services that open, a work index with grid or table, project pages and a brief form.
// Ballmac UI: Studio template fonts. https://ui.ballmac.com/templates/template-studio
import { Archivo, Geist_Mono } from "next/font/google"
/** Archivo has a width axis, so headlines can run wide and heavy while body copy stays normal. */
export const studioSans = Archivo({ variable: "--studio-sans", subsets: ["latin"], axes: ["wdth"], display: "swap", preload: false })
export const studioMono = Geist_Mono({ variable: "--studio-mono", subsets: ["latin"], display: "swap", preload: false })import type { MediaSource } from "@/components/ballmac/media"
// Ballmac UI: Studio sample content. https://ui.ballmac.com/templates/template-studio
export type StudioProject = { slug: string; name: string; client: string; year: string; discipline: "Brand" | "Digital" | "Campaign"; line: string; art: number; image?: MediaSource; imageAlt?: string }
export const projects: StudioProject[] = [
{ slug: "north-coast", name: "North Coast Rail", client: "North Coast", year: "2026", discipline: "Brand", line: "A railway identity that moves.", art: 0 },
{ slug: "oat-and-ember", name: "Oat & Ember", client: "Oat & Ember", year: "2026", discipline: "Campaign", line: "A launch that sold out in nine days.", art: 1 },
{ slug: "meridian", name: "Meridian Bank", client: "Meridian", year: "2025", discipline: "Digital", line: "Banking without the small print.", art: 2 },
{ slug: "tidal", name: "Tidal Festival", client: "Tidal", year: "2025", discipline: "Brand", line: "Three days, one living identity.", art: 3 },
{ slug: "pilot", name: "Pilot Coffee", client: "Pilot", year: "2025", discipline: "Campaign", line: "Packaging that earned its shelf.", art: 4 },
{ slug: "alder", name: "Alder & Co", client: "Alder", year: "2024", discipline: "Digital", line: "A furniture shop built on stories.", art: 5 },
]
export const services = [
{ n: "01", title: "Brand identity", text: "Strategy, naming, logos, typefaces and the system that holds them together. We leave you with a brand people can actually use on a Tuesday.", items: ["Positioning", "Naming", "Visual identity", "Brand guidelines"], from: "From €45k" },
{ n: "02", title: "Digital design", text: "Websites and products with a point of view. Designed in the browser, built with your team, and measured after launch.", items: ["Websites", "Product design", "Design systems", "Motion"], from: "From €60k" },
{ n: "03", title: "Campaigns", text: "Ideas that travel. We direct, shoot and launch across print, social, film and the street.", items: ["Concept", "Art direction", "Film and photography", "Launch"], from: "From €35k" },
{ n: "04", title: "Retained studio", text: "A senior team on call for a monthly fee. For brands that ship constantly and want one look across everything.", items: ["Monthly capacity", "Quarterly roadmap", "Priority access"], from: "From €12k / month" },
]
export const process = [
["Listen", "Two weeks of interviews, audits and arguments. We write down what is true before we make anything pretty."],
["Make", "Small, fast, ugly first. You see work every week, not a reveal at the end."],
["Launch", "We stay until it is live, then measure it. The work isn't finished when it is delivered."],
]// Ballmac UI: Studio template shell. https://ui.ballmac.com/templates/template-studio
"use client"
import * as React from "react"
import { ArrowUpRight, Menu, X } from "lucide-react"
import { studioMono, studioSans } from "@/components/ballmac/templates/studio/studio-fonts"
import { cn } from "@/lib/utils"
import { Media, type MediaSource } from "@/components/ballmac/media"
type StudioPage = "home" | "work" | "project" | "services" | "contact"
type StudioHrefs = Record<StudioPage, string>
const defaultHrefs: StudioHrefs = { home: "/studio", work: "/studio/work", project: "/studio/work/north-coast", services: "/studio/services", contact: "/studio/contact" }
/** Studio's palette: stark white and black with a hot orange, plus electric blue, acid yellow, pink and mint for art. */
const studioCss = `
.studio-theme,body:not(:has([data-gallery])):has(.studio-theme){--background:oklch(0.99 0 0);--foreground:oklch(0.12 0 0);--card:oklch(1 0 0);--card-foreground:oklch(0.12 0 0);--popover:oklch(1 0 0);--popover-foreground:oklch(0.12 0 0);--primary:oklch(0.12 0 0);--primary-foreground:oklch(0.99 0 0);--secondary:oklch(0.955 0 0);--secondary-foreground:oklch(0.12 0 0);--muted:oklch(0.955 0 0);--muted-foreground:oklch(0.44 0 0);--accent:oklch(0.93 0.02 40);--accent-foreground:oklch(0.12 0 0);--border:oklch(0.12 0 0 / 16%);--input:oklch(0.12 0 0 / 28%);--ring:oklch(0.12 0 0);--surface:oklch(0.965 0 0);--destructive:oklch(0.52 0.24 27);--chart-1:oklch(0.68 0.22 40);--chart-2:oklch(0.52 0.24 265);--chart-3:oklch(0.92 0.18 100);--chart-4:oklch(0.72 0.22 350);--chart-5:oklch(0.85 0.13 170);--studio-on-accent:oklch(0.12 0 0);--radius:0.25rem}
.dark .studio-theme,.dark body:not(:has([data-gallery])):has(.studio-theme){--background:oklch(0.12 0 0);--foreground:oklch(0.97 0 0);--card:oklch(0.16 0 0);--card-foreground:oklch(0.97 0 0);--popover:oklch(0.18 0 0);--popover-foreground:oklch(0.97 0 0);--primary:oklch(0.97 0 0);--primary-foreground:oklch(0.12 0 0);--secondary:oklch(0.2 0 0);--secondary-foreground:oklch(0.97 0 0);--muted:oklch(0.2 0 0);--muted-foreground:oklch(0.72 0 0);--accent:oklch(0.24 0.02 40);--accent-foreground:oklch(0.97 0 0);--border:oklch(1 0 0 / 16%);--input:oklch(1 0 0 / 28%);--ring:oklch(0.97 0 0);--surface:oklch(0.15 0 0);--destructive:oklch(0.7 0.2 27);--chart-1:oklch(0.7 0.21 40);--chart-2:oklch(0.52 0.24 265);--chart-3:oklch(0.92 0.18 100);--chart-4:oklch(0.74 0.2 350);--chart-5:oklch(0.85 0.13 170);--studio-on-accent:oklch(0.12 0 0)}
body:not(:has([data-gallery])):has(.studio-theme){font-family:var(--studio-sans),ui-sans-serif,system-ui,sans-serif}
`
/** Wide, heavy headline settings for Archivo. */
const display = "[font-stretch:112%] font-extrabold uppercase tracking-[-0.03em] leading-[0.88]"
/** A painted poster: bold shapes in the accent colours, standing in for project imagery. */
function StudioArt({ 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-1 absolute inset-0" /><div className="bg-foreground absolute -bottom-[20%] -start-[10%] w-[80%] aspect-square rounded-full" /><div className="bg-chart-3 absolute top-[12%] end-[12%] w-[26%] aspect-square rounded-full" /></>)}
{v === 1 && (<><div className="bg-chart-2 absolute inset-0" /><div className="bg-chart-3 absolute inset-y-0 start-[18%] w-[22%]" /><div className="bg-chart-4 absolute inset-y-0 start-[48%] w-[12%]" /><div className="bg-primary-foreground absolute top-[18%] end-[10%] h-[64%] w-[16%] rounded-full" /></>)}
{v === 2 && (<><div className="bg-foreground absolute inset-0" />{[0, 1, 2, 3, 4].map((i) => <div key={i} className="border-primary-foreground absolute inset-x-[10%] rounded-full border-[3px]" style={{ top: `${10 + i * 12}%`, height: `${60 - i * 8}%`, opacity: 1 - i * 0.16 }} />)}<div className="bg-chart-1 absolute end-[14%] bottom-[14%] w-[20%] aspect-square rounded-full" /></>)}
{v === 3 && (<><div className="bg-chart-4 absolute inset-0" /><div className="absolute inset-[8%] grid grid-cols-4 grid-rows-3 gap-[2%]">{Array.from({ length: 12 }, (_, i) => <div key={i} className={cn("rounded-full", i % 5 === 0 ? "bg-chart-3" : i % 3 === 0 ? "bg-foreground" : "bg-primary-foreground")} />)}</div></>)}
{v === 4 && (<><div className="bg-chart-3 absolute inset-0" /><div className="bg-foreground absolute top-[16%] start-[22%] h-[68%] w-[56%] rounded-t-[999px]" /><div className="bg-chart-1 absolute bottom-[16%] start-[34%] w-[32%] aspect-square rounded-full" /></>)}
{v === 5 && (<><div className="bg-chart-5 absolute inset-0" /><div className="bg-foreground absolute inset-x-0 bottom-0 h-[38%]" /><div className="bg-chart-2 absolute bottom-[38%] start-[12%] h-[34%] w-[28%]" /><div className="bg-chart-1 absolute end-[14%] bottom-[38%] h-[52%] w-[22%] rounded-t-full" /></>)}
</div>
)
}
const links: { key: StudioPage; label: string }[] = [
{ key: "work", label: "Work" },
{ key: "services", label: "Services" },
{ key: "contact", label: "Contact" },
]
type StudioShellProps = React.ComponentProps<"div"> & {
/** The page being shown, so its nav link is marked current. */
page: StudioPage
/** Override where pages live (used by previews). */
hrefs?: Partial<StudioHrefs>
}
/** Studio's frame: a heavy wordmark, a plain nav, and a footer that is mostly the studio's name. */
function StudioShell({ page, hrefs: overrides, className, style, children, ...props }: StudioShellProps) {
const hrefs = { ...defaultHrefs, ...overrides }
const [open, setOpen] = React.useState(false)
React.useEffect(() => {
const classes = [studioSans.variable, studioMono.variable].filter(Boolean)
document.body.classList.add(...classes)
return () => document.body.classList.remove(...classes)
}, [])
const active = page === "project" ? "work" : page
return (
<div
data-slot="studio"
className={cn("studio-theme bg-background text-foreground relative min-h-dvh overflow-x-clip", studioSans.variable, studioMono.variable, className)}
style={{ fontFamily: "var(--studio-sans), ui-sans-serif, system-ui, sans-serif", ...style }}
{...props}
>
<style>{studioCss}</style>
<header className="bg-background/90 sticky top-0 z-40 border-b backdrop-blur-lg">
<div className="mx-auto flex h-14 max-w-[100rem] items-center justify-between px-4 sm:px-8">
<a href={hrefs.home} className={cn("focus-visible:ring-ring/50 rounded text-xl outline-none focus-visible:ring-[3px]", display)}>Hollis<span className="text-chart-1">&</span>Vane</a>
<nav aria-label="Main" className="hidden items-center gap-8 md:flex">
{links.map((l) => <a key={l.key} href={hrefs[l.key]} aria-current={active === l.key ? "page" : undefined} className="hover:text-chart-1 aria-[current=page]:underline decoration-chart-1 focus-visible:ring-ring/50 rounded text-sm font-semibold tracking-wide uppercase underline-offset-[10px] outline-none transition-colors aria-[current=page]:decoration-[3px] focus-visible:ring-[3px]">{l.label}</a>)}
</nav>
<p className="text-muted-foreground hidden text-xs lg:block" style={{ fontFamily: "var(--studio-mono)" }}>Lisbon · Berlin</p>
<button type="button" aria-label={open ? "Close menu" : "Open menu"} aria-expanded={open} aria-controls="studio-mobile-menu" onClick={() => setOpen((v) => !v)} className="hover:bg-accent focus-visible:ring-ring/50 inline-flex size-10 items-center justify-center rounded 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="studio-mobile-menu" aria-label="Mobile" className="border-t px-4 py-3 md:hidden">
{links.map((l) => <a key={l.key} href={hrefs[l.key]} className={cn("hover:text-chart-1 block py-2.5 text-3xl", display)}>{l.label}</a>)}
</nav>
)}
</header>
{children}
<footer className="bg-foreground text-background mt-24">
<div className="mx-auto max-w-[100rem] px-4 pt-16 pb-8 sm:px-8">
<div className="flex flex-wrap items-end justify-between gap-8">
<p className="max-w-md text-xl text-pretty opacity-80">New project, new idea, or just a question? We reply to every email within two working days.</p>
<a href={hrefs.contact} className="bg-chart-1 text-[var(--studio-on-accent)] focus-visible:ring-ring inline-flex h-14 items-center gap-2 rounded-full px-8 text-lg font-bold outline-none transition-transform hover:-translate-y-0.5 focus-visible:ring-[3px] motion-reduce:transition-none">Start a project <ArrowUpRight className="size-5 rtl:-scale-x-100" aria-hidden="true" /></a>
</div>
<p aria-hidden="true" className={cn("mt-14 text-[clamp(3.4rem,17.5vw,19rem)] whitespace-nowrap", display)}>Hollis&Vane</p>
<div className="mt-8 flex flex-wrap justify-between gap-4 border-t border-current/25 pt-6 text-sm opacity-70">
<p>© 2026 Hollis & Vane Studio Ltd.</p>
<ul className="flex gap-6">{["Instagram", "Behance", "LinkedIn", "Newsletter"].map((l) => <li key={l}><a href="#" className="hover:underline">{l}</a></li>)}</ul>
</div>
</div>
</footer>
</div>
)
}
export { StudioArt, StudioShell, defaultHrefs as studioDefaultHrefs, display as studioDisplay, type StudioHrefs, type StudioPage, type StudioShellProps }// Ballmac UI: Studio home page. https://ui.ballmac.com/templates/template-studio
"use client"
import * as React from "react"
import { ArrowUpRight } from "lucide-react"
import { useReducedMotion } from "motion/react"
import { Marquee } from "@/components/ballmac/marquee"
import { process, projects, services } from "@/components/ballmac/templates/studio/studio-data"
import { StudioArt, StudioShell, studioDisplay, type StudioHrefs } from "@/components/ballmac/templates/studio/studio-theme"
import { cn } from "@/lib/utils"
const mono = { fontFamily: "var(--studio-mono)" } as const
/** A list of projects. Hover or focus a row and a poster follows the pointer (or sits beside the row for keyboard users). */
function WorkReel({ href }: { href: string }) {
const reduce = useReducedMotion()
const [active, setActive] = React.useState<number | null>(null)
const [mode, setMode] = React.useState<"pointer" | "focus">("pointer")
const box = React.useRef<HTMLDivElement>(null)
const card = React.useRef<HTMLDivElement>(null)
function move(e: React.PointerEvent) {
const r = box.current?.getBoundingClientRect()
if (!r || !card.current || reduce) return
card.current.style.transform = `translate(${e.clientX - r.left + 28}px, ${e.clientY - r.top - 120}px)`
}
const shown = active !== null ? projects[active] : null
return (
<div ref={box} className="relative" onPointerMove={move} onPointerLeave={() => setActive(null)}>
<ul className="border-t-2 border-current">
{projects.map((p, i) => (
<li key={p.slug} className="border-b">
<a
href={href}
onPointerEnter={() => { setMode("pointer"); setActive(i) }}
onFocus={() => { setMode("focus"); setActive(i) }}
onBlur={() => setActive(null)}
className="hover:bg-chart-1 hover:text-[var(--studio-on-accent)] focus-visible:bg-chart-1 focus-visible:text-[var(--studio-on-accent)] group grid grid-cols-[1fr_auto] items-baseline gap-4 px-2 py-6 outline-none transition-colors sm:grid-cols-[3rem_1fr_10rem_5rem] sm:px-4 motion-reduce:transition-none"
>
<span className="text-muted-foreground group-hover:text-[var(--studio-on-accent)] group-focus-visible:text-[var(--studio-on-accent)] hidden text-sm sm:block" style={mono}>{String(i + 1).padStart(2, "0")}</span>
<span className={cn("text-[clamp(2rem,6.4vw,5.5rem)] text-balance", studioDisplay)}>{p.name}</span>
<span className="group-hover:text-[var(--studio-on-accent)] group-focus-visible:text-[var(--studio-on-accent)] text-muted-foreground hidden text-sm sm:block" style={mono}>{p.discipline}</span>
<span className="group-hover:text-[var(--studio-on-accent)] group-focus-visible:text-[var(--studio-on-accent)] text-muted-foreground text-end text-sm tabular-nums" style={mono}>{p.year}</span>
</a>
</li>
))}
</ul>
<div
ref={card}
aria-hidden="true"
className={cn("pointer-events-none absolute top-0 start-0 z-10 hidden w-72 will-change-transform md:block", shown ? "opacity-100" : "opacity-0", "transition-opacity duration-200 motion-reduce:transition-none")}
style={mode === "focus" || reduce ? { transform: `translate(calc(100% - 20rem), ${(active ?? 0) * 7.2}rem)` } : undefined}
>
{shown && (<div className="border-foreground bg-background overflow-hidden rounded border-2 shadow-2xl"><StudioArt variant={shown.art} image={shown.image} imageAlt={shown.imageAlt} /><p className="border-t-2 px-3 py-2 text-sm font-semibold">{shown.line}</p></div>)}
</div>
</div>
)
}
type StudioHomeProps = React.ComponentProps<"div"> & { hrefs?: Partial<StudioHrefs> }
/** The Studio home page: a huge statement, a hover-reveal work reel, services, process and a loud call to action. */
function StudioHome({ hrefs, ...props }: StudioHomeProps) {
const h = { work: hrefs?.work ?? "/studio/work", project: hrefs?.project ?? "/studio/work/north-coast", services: hrefs?.services ?? "/studio/services", contact: hrefs?.contact ?? "/studio/contact" }
return (
<StudioShell page="home" hrefs={hrefs} {...props}>
<main>
<section className="mx-auto max-w-[100rem] px-4 pt-10 pb-12 sm:px-8 sm:pt-16">
<p className="text-muted-foreground text-sm" style={mono}>Independent branding & digital studio · est. 2016</p>
<h1 className={cn("mt-6 text-[clamp(2.8rem,9.2vw,10rem)]", studioDisplay)}>
<span className="block">We make</span>
<span className="block">brands that</span>
<span className="block"><span className="bg-chart-1 inline-block px-[0.12em] text-[var(--studio-on-accent)]">move</span> people.</span>
</h1>
<div className="mt-12 grid gap-8 sm:grid-cols-[1fr_auto] sm:items-end">
<p className="max-w-xl text-xl text-pretty sm:text-2xl">Hollis & Vane is a 14-person studio in Lisbon and Berlin. We design identities, websites and campaigns for people who would rather be remembered than liked.</p>
<a href={h.work} className="bg-foreground text-background focus-visible:ring-ring/50 inline-flex h-14 items-center gap-2 rounded-full px-8 text-lg font-bold outline-none transition-transform hover:-translate-y-0.5 focus-visible:ring-[3px] motion-reduce:transition-none">See the work <ArrowUpRight className="size-5 rtl:-scale-x-100" aria-hidden="true" /></a>
</div>
</section>
<section aria-label="Clients" className="border-foreground bg-foreground text-background border-y-2 py-5">
<Marquee speed={60} gap={56} pauseOnHover>
{["North Coast", "Oat & Ember", "Meridian", "Tidal", "Pilot", "Alder & Co", "Fernhill", "Kestrel"].map((c) => <span key={c} className={cn("flex items-center gap-14 text-4xl sm:text-5xl", studioDisplay)}>{c}<span className="bg-chart-1 size-4 rounded-full" aria-hidden="true" /></span>)}
</Marquee>
</section>
<section id="work" aria-labelledby="st-work" className="mx-auto max-w-[100rem] px-4 py-24 sm:px-8">
<div className="mb-10 flex items-end justify-between gap-4"><h2 id="st-work" className={cn("text-[clamp(2.4rem,7vw,6rem)]", studioDisplay)}>Selected work</h2><p className="text-muted-foreground hidden text-sm sm:block" style={mono}>Hover a row</p></div>
<WorkReel href={h.project} />
</section>
<section aria-labelledby="st-services" className="border-t-2 border-current">
<div className="mx-auto grid max-w-[100rem] gap-12 px-4 py-24 sm:px-8 lg:grid-cols-[1fr_2fr]">
<div><h2 id="st-services" className={cn("text-[clamp(2.4rem,6vw,5rem)]", studioDisplay)}>What we do</h2><a href={h.services} className="mt-6 inline-flex items-center gap-2 font-bold underline decoration-2 underline-offset-8">All services <ArrowUpRight className="size-4 rtl:-scale-x-100" aria-hidden="true" /></a></div>
<ul className="divide-y-2 border-y-2">
{services.map((s) => (
<li key={s.n} className="grid gap-3 py-8 sm:grid-cols-[4rem_1fr]">
<span className="bg-chart-1 h-fit w-fit px-2 py-0.5 text-lg font-bold text-[var(--studio-on-accent)]" style={mono}>{s.n}</span>
<div><h3 className={cn("text-3xl", studioDisplay)}>{s.title}</h3><p className="text-muted-foreground mt-3 max-w-xl text-pretty">{s.text}</p></div>
</li>
))}
</ul>
</div>
</section>
<section aria-labelledby="st-process" className="bg-chart-2 text-white">
<div className="mx-auto max-w-[100rem] px-4 py-24 sm:px-8">
<h2 id="st-process" className={cn("text-[clamp(2.4rem,6vw,5rem)]", studioDisplay)}>How we work</h2>
<ol className="mt-14 grid gap-px bg-white/30 md:grid-cols-3">
{process.map(([t, d], i) => (
<li key={t} className="bg-chart-2 p-8 md:p-10">
<span className="text-sm" style={mono}>Step {i + 1}</span>
<h3 className={cn("mt-4 text-5xl", studioDisplay)}>{t}</h3>
<p className="mt-5 text-lg text-pretty opacity-90">{d}</p>
</li>
))}
</ol>
</div>
</section>
<section className="mx-auto max-w-[100rem] px-4 py-24 sm:px-8">
<blockquote className={cn("max-w-6xl text-[clamp(1.8rem,4.6vw,4rem)] leading-[1.05] normal-case", studioDisplay.replace("uppercase", ""))}>“They didn’t give us a logo. They gave us a way of behaving, and now the whole company does it.”</blockquote>
<p className="text-muted-foreground mt-8 text-sm" style={mono}>Anaïs Dupont · CMO, North Coast Rail</p>
</section>
<section className="bg-chart-1 text-[var(--studio-on-accent)]">
<a href={h.contact} className="group focus-visible:ring-ring mx-auto flex max-w-[100rem] items-center justify-between gap-6 px-4 py-16 outline-none focus-visible:ring-[3px] focus-visible:ring-inset sm:px-8 sm:py-24">
<span className={cn("text-[clamp(3rem,11vw,10rem)]", studioDisplay)}>Let’s talk</span>
<ArrowUpRight className="size-[clamp(3rem,10vw,9rem)] shrink-0 transition-transform duration-300 group-hover:translate-x-2 group-hover:-translate-y-2 motion-reduce:transition-none rtl:-scale-x-100 rtl:group-hover:-translate-x-2" strokeWidth={1.5} aria-hidden="true" />
</a>
</section>
</main>
</StudioShell>
)
}
export { StudioHome, type StudioHomeProps }// Ballmac UI: Studio work page. https://ui.ballmac.com/templates/template-studio
"use client"
import * as React from "react"
import { LayoutGrid, List } from "lucide-react"
import { projects } from "@/components/ballmac/templates/studio/studio-data"
import { StudioArt, StudioShell, studioDisplay, type StudioHrefs } from "@/components/ballmac/templates/studio/studio-theme"
import { cn } from "@/lib/utils"
const mono = { fontFamily: "var(--studio-mono)" } as const
type Filter = "All" | "Brand" | "Digital" | "Campaign"
const filters: Filter[] = ["All", "Brand", "Digital", "Campaign"]
type StudioWorkProps = React.ComponentProps<"div"> & { hrefs?: Partial<StudioHrefs> }
/** The Studio work index: every project as a table or a poster grid, filtered by discipline. */
function StudioWork({ hrefs, ...props }: StudioWorkProps) {
const [filter, setFilter] = React.useState<Filter>("All")
const [view, setView] = React.useState<"list" | "grid">("grid")
const shown = projects.filter((p) => filter === "All" || p.discipline === filter)
const href = hrefs?.project ?? "/studio/work/north-coast"
return (
<StudioShell page="work" hrefs={hrefs} {...props}>
<main className="mx-auto max-w-[100rem] px-4 pt-10 sm:px-8 sm:pt-16">
<h1 className={cn("text-[clamp(3rem,13vw,13rem)]", studioDisplay)}>Work <span className="text-muted-foreground text-[0.22em] align-top tabular-nums" style={mono}>({shown.length})</span></h1>
<div className="mt-10 flex flex-wrap items-center gap-3 border-y-2 py-4">
<div role="group" aria-label="Filter by discipline" className="flex flex-wrap gap-2">
{filters.map((f) => <button key={f} type="button" aria-pressed={filter === f} onClick={() => setFilter(f)} className={cn("focus-visible:ring-ring/50 h-11 rounded-full border-2 border-current px-5 text-sm font-bold tracking-wide uppercase outline-none transition-colors focus-visible:ring-[3px]", filter === f ? "bg-foreground text-background" : "hover:bg-chart-1 hover:text-[var(--studio-on-accent)]")}>{f}</button>)}
</div>
<div role="group" aria-label="Layout" className="ms-auto flex gap-1">
{([["grid", LayoutGrid, "Grid"], ["list", List, "List"]] as const).map(([v, Icon, l]) => <button key={v} type="button" aria-pressed={view === v} aria-label={`${l} view`} onClick={() => setView(v)} className={cn("focus-visible:ring-ring/50 inline-flex size-11 items-center justify-center rounded-full border-2 outline-none focus-visible:ring-[3px]", view === v ? "bg-foreground text-background border-transparent" : "hover:bg-accent border-current")}><Icon className="size-4" aria-hidden="true" /></button>)}
</div>
</div>
<p className="sr-only" role="status">{shown.length} projects shown</p>
{view === "grid" ? (
<ul className="mt-10 grid gap-x-6 gap-y-14 sm:grid-cols-2 lg:grid-cols-3">
{shown.map((p) => (
<li key={p.slug}>
<a href={href} className="group focus-visible:ring-ring/50 block outline-none focus-visible:ring-[3px] focus-visible:ring-offset-4 focus-visible:ring-offset-background">
<div className="border-foreground overflow-hidden border-2"><StudioArt variant={p.art} image={p.image} imageAlt={p.imageAlt} className="transition-transform duration-500 group-hover:scale-105 group-focus-visible:scale-105 motion-reduce:transition-none" /></div>
<div className="mt-4 flex items-baseline justify-between gap-3"><h2 className={cn("text-3xl group-hover:underline decoration-chart-1 decoration-4 underline-offset-4", studioDisplay)}>{p.name}</h2><span className="text-muted-foreground text-sm tabular-nums" style={mono}>{p.year}</span></div>
<p className="text-muted-foreground mt-1 text-sm" style={mono}>{p.discipline} · {p.line}</p>
</a>
</li>
))}
</ul>
) : (
<div className="mt-6 overflow-x-auto">
<table className="w-full min-w-[34rem] text-start">
<caption className="sr-only">All projects</caption>
<thead><tr className="text-muted-foreground border-b text-xs uppercase" style={mono}><th scope="col" className="py-3 font-normal">Project</th><th scope="col" className="py-3 font-normal">Discipline</th><th scope="col" className="py-3 font-normal">Client</th><th scope="col" className="py-3 text-end font-normal">Year</th></tr></thead>
<tbody>
{shown.map((p) => (
<tr key={p.slug} className="hover:bg-chart-1 hover:text-[var(--studio-on-accent)] border-b transition-colors motion-reduce:transition-none">
<th scope="row" className="py-5 pe-4"><a href={href} className={cn("focus-visible:ring-ring/50 rounded text-2xl outline-none focus-visible:ring-[3px] sm:text-4xl", studioDisplay)}>{p.name}</a></th>
<td className="py-5 text-sm" style={mono}>{p.discipline}</td>
<td className="py-5 text-sm" style={mono}>{p.client}</td>
<td className="py-5 text-end text-sm tabular-nums" style={mono}>{p.year}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</main>
</StudioShell>
)
}
export { StudioWork, type StudioWorkProps }// Ballmac UI: Studio project page. https://ui.ballmac.com/templates/template-studio
"use client"
import * as React from "react"
import { ArrowUpRight } from "lucide-react"
import { projects } from "@/components/ballmac/templates/studio/studio-data"
import { StudioArt, StudioShell, studioDisplay, type StudioHrefs } from "@/components/ballmac/templates/studio/studio-theme"
import { cn } from "@/lib/utils"
const mono = { fontFamily: "var(--studio-mono)" } as const
const facts = [["Client", "North Coast Rail"], ["Year", "2026"], ["Services", "Identity, signage, motion, website"], ["Team", "6 people, 11 months"]]
const credits = [["Creative direction", "Inês Hollis"], ["Design lead", "Jonas Vane"], ["Motion", "Mei Tanaka"], ["Development", "Colm Brennan"], ["Strategy", "Amara Okafor"]]
type StudioProjectProps = React.ComponentProps<"div"> & { hrefs?: Partial<StudioHrefs> }
/** A Studio project page: a full-bleed poster, a fact line, big imagery in two rhythms, a pull quote and credits. */
function StudioProject({ hrefs, ...props }: StudioProjectProps) {
const next = projects[1]
return (
<StudioShell page="project" hrefs={hrefs} {...props}>
<main>
<header className="mx-auto max-w-[100rem] px-4 pt-10 sm:px-8 sm:pt-16">
<p className="text-muted-foreground text-sm" style={mono}><a href={hrefs?.work ?? "/studio/work"} className="underline-offset-4 hover:underline">Work</a> / Brand</p>
<h1 className={cn("mt-4 text-[clamp(3rem,12vw,12rem)]", studioDisplay)}>North Coast Rail</h1>
<p className="mt-6 max-w-3xl text-2xl text-pretty sm:text-3xl">A railway identity that moves: from a logo built from a single line to 400 trains that carry it.</p>
</header>
<StudioArt variant={0} className="border-foreground mt-12 aspect-[16/8] border-y-2" />
<dl className="mx-auto grid max-w-[100rem] gap-6 px-4 py-10 sm:grid-cols-2 sm:px-8 lg:grid-cols-4">
{facts.map(([k, v]) => <div key={k}><dt className="text-muted-foreground text-xs uppercase" style={mono}>{k}</dt><dd className="mt-2 text-lg font-semibold">{v}</dd></div>)}
</dl>
<section className="mx-auto max-w-[100rem] px-4 py-12 sm:px-8">
<div className="grid gap-12 lg:grid-cols-[1fr_2fr]">
<h2 className={cn("text-4xl", studioDisplay)}>The idea</h2>
<p className="max-w-3xl text-2xl leading-snug text-pretty sm:text-3xl">A railway is one long line with many stops. We drew the identity the same way: a single continuous stroke that bends into the letters, the map and the livery. It is simple enough to paint on a train and distinctive enough to recognise from the platform.</p>
</div>
</section>
<section className="mx-auto max-w-[100rem] space-y-4 px-4 sm:px-8">
<div className="grid gap-4 sm:grid-cols-2"><StudioArt variant={1} className="border-foreground border-2" /><StudioArt variant={4} className="border-foreground border-2" /></div>
<StudioArt variant={3} className="border-foreground aspect-[16/7] border-2" />
</section>
<section className="bg-chart-1 mt-20 text-[var(--studio-on-accent)]">
<figure className="mx-auto max-w-[100rem] px-4 py-20 sm:px-8 sm:py-28">
<blockquote className={cn("max-w-6xl text-[clamp(2rem,5.6vw,5rem)]", studioDisplay)}>“Passengers started photographing the trains. We have never had that before.”</blockquote>
<figcaption className="mt-8 text-sm" style={mono}>Anaïs Dupont, CMO, North Coast Rail</figcaption>
</figure>
</section>
<section aria-labelledby="sp-credits" className="mx-auto max-w-[100rem] px-4 py-20 sm:px-8">
<h2 id="sp-credits" className={cn("text-4xl", studioDisplay)}>Credits</h2>
<dl className="mt-8 divide-y-2 border-y-2">{credits.map(([k, v]) => <div key={k} className="grid grid-cols-2 py-4 sm:grid-cols-[16rem_1fr]"><dt className="text-muted-foreground text-sm" style={mono}>{k}</dt><dd className="font-semibold">{v}</dd></div>)}</dl>
</section>
<a href={hrefs?.project ?? "/studio/work/oat-and-ember"} className="group border-foreground focus-visible:ring-ring/50 block border-t-2 outline-none focus-visible:ring-[3px] focus-visible:ring-inset">
<div className="mx-auto flex max-w-[100rem] items-center justify-between gap-6 px-4 py-14 sm:px-8">
<div><p className="text-muted-foreground text-sm" style={mono}>Next project</p><p className={cn("group-hover:text-chart-1 mt-3 text-[clamp(2.4rem,8vw,8rem)] transition-colors motion-reduce:transition-none", studioDisplay)}>{next.name}</p></div>
<ArrowUpRight className="size-14 shrink-0 transition-transform group-hover:translate-x-2 group-hover:-translate-y-2 motion-reduce:transition-none rtl:-scale-x-100 rtl:group-hover:-translate-x-2" strokeWidth={1.5} aria-hidden="true" />
</div>
</a>
</main>
</StudioShell>
)
}
export { StudioProject, type StudioProjectProps }// Ballmac UI: Studio services page. https://ui.ballmac.com/templates/template-studio
"use client"
import * as React from "react"
import { Minus, Plus } from "lucide-react"
import { services } from "@/components/ballmac/templates/studio/studio-data"
import { StudioShell, studioDisplay, type StudioHrefs } from "@/components/ballmac/templates/studio/studio-theme"
import { cn } from "@/lib/utils"
const mono = { fontFamily: "var(--studio-mono)" } as const
type StudioServicesProps = React.ComponentProps<"div"> & { hrefs?: Partial<StudioHrefs> }
/** The Studio services page: four numbered disciplines that open to show what's inside, with a starting price. */
function StudioServices({ hrefs, ...props }: StudioServicesProps) {
const [open, setOpen] = React.useState<string | null>("01")
return (
<StudioShell page="services" hrefs={hrefs} {...props}>
<main className="mx-auto max-w-[100rem] px-4 pt-10 sm:px-8 sm:pt-16">
<h1 className={cn("text-[clamp(3rem,12vw,12rem)]", studioDisplay)}>Services</h1>
<p className="mt-6 max-w-2xl text-2xl text-pretty">Four ways to work with us. Most projects start with one and grow into two.</p>
<ul className="mt-14 border-t-2">
{services.map((s) => {
const on = open === s.n
return (
<li key={s.n} className="border-b-2">
<h2>
<button type="button" aria-expanded={on} aria-controls={`ss-${s.n}`} onClick={() => setOpen(on ? null : s.n)} className="hover:bg-accent focus-visible:ring-ring/50 grid w-full grid-cols-[3.5rem_1fr_auto] items-center gap-4 px-2 py-7 text-start outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-inset sm:grid-cols-[6rem_1fr_auto] sm:px-4 motion-reduce:transition-none">
<span className="bg-chart-1 h-fit w-fit px-2 py-0.5 text-lg font-bold text-[var(--studio-on-accent)]" style={mono}>{s.n}</span>
<span className={cn("text-[clamp(1.8rem,5vw,4.5rem)]", studioDisplay)}>{s.title}</span>
{on ? <Minus className="size-8" aria-hidden="true" /> : <Plus className="size-8" aria-hidden="true" />}
</button>
</h2>
<div id={`ss-${s.n}`} role="region" aria-labelledby={undefined} hidden={!on} className="grid gap-8 px-2 pb-10 sm:grid-cols-[6rem_1fr_16rem] sm:px-4">
<span aria-hidden="true" className="hidden sm:block" />
<div><p className="max-w-2xl text-xl text-pretty">{s.text}</p><ul className="mt-6 flex flex-wrap gap-2">{s.items.map((i) => <li key={i} className="rounded-full border-2 border-current px-4 py-1.5 text-sm font-semibold">{i}</li>)}</ul></div>
<p className="bg-chart-3 h-fit rounded px-4 py-3 text-lg font-bold text-[var(--studio-on-accent)]" style={mono}>{s.from}</p>
</div>
</li>
)
})}
</ul>
<section aria-labelledby="ss-faq" className="grid gap-10 py-24 lg:grid-cols-[1fr_2fr]">
<h2 id="ss-faq" className={cn("text-5xl", studioDisplay)}>Before you ask</h2>
<dl className="divide-y-2 border-y-2">
{[["Do you work with startups?", "Yes, if the founders are the ones in the room. We take two a quarter."], ["How long does a project take?", "An identity takes 10 to 14 weeks. A website, 12 to 20. A campaign depends on the shoot."], ["Can you work with our team?", "Always. Most of our best work was built alongside in-house designers and developers."]].map(([q, a]) => <div key={q} className="py-6"><dt className="text-xl font-bold">{q}</dt><dd className="text-muted-foreground mt-2 max-w-xl text-pretty">{a}</dd></div>)}
</dl>
</section>
</main>
</StudioShell>
)
}
export { StudioServices, type StudioServicesProps }// Ballmac UI: Studio contact page. https://ui.ballmac.com/templates/template-studio
"use client"
import * as React from "react"
import { Check } from "lucide-react"
import { CopyButton } from "@/components/ballmac/copy-button"
import { StudioShell, studioDisplay, type StudioHrefs } from "@/components/ballmac/templates/studio/studio-theme"
import { cn } from "@/lib/utils"
const mono = { fontFamily: "var(--studio-mono)" } as const
const budgets = ["Under €30k", "€30–60k", "€60–120k", "€120k+"]
const field = "bg-background focus-visible:ring-ring/50 mt-2 h-14 w-full border-2 border-current px-4 text-lg outline-none focus-visible:ring-[3px] aria-[invalid=true]:border-destructive"
type StudioContactProps = React.ComponentProps<"div"> & { hrefs?: Partial<StudioHrefs> }
/** The Studio contact page: a huge email, a project brief form with chips and inline errors, and the two offices. */
function StudioContact({ hrefs, ...props }: StudioContactProps) {
const [budget, setBudget] = React.useState("")
const [errors, setErrors] = React.useState<{ name?: string; email?: string; brief?: string }>({})
const [sent, setSent] = React.useState<string | null>(null)
function submit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
const f = new FormData(e.currentTarget)
const name = String(f.get("name") ?? "").trim()
const email = String(f.get("email") ?? "").trim()
const brief = String(f.get("brief") ?? "").trim()
const next: typeof errors = {}
if (!name) next.name = "Your name, please."
if (!/^\S+@\S+\.\S+$/.test(email)) next.email = "That email doesn’t look right."
if (brief.length < 20) next.brief = "Tell us a little more: at least a sentence."
setErrors(next)
const first = (["name", "email", "brief"] as const).find((k) => next[k])
if (first) {
;(e.currentTarget.elements.namedItem(first) as HTMLElement | null)?.focus()
return
}
setSent(name.split(" ")[0])
}
return (
<StudioShell page="contact" hrefs={hrefs} {...props}>
<main className="mx-auto max-w-[100rem] px-4 pt-10 sm:px-8 sm:pt-16">
<h1 className={cn("text-[clamp(3rem,12vw,12rem)]", studioDisplay)}>Say hello</h1>
<div className="mt-8 flex flex-wrap items-center gap-3">
<a href="mailto:hello@hollisvane.example" className="focus-visible:ring-ring/50 decoration-chart-1 rounded text-[clamp(1.4rem,4.4vw,3.6rem)] font-bold tracking-tight break-all underline decoration-4 underline-offset-8 outline-none focus-visible:ring-[3px]">hello@hollisvane.example</a>
<CopyButton value="hello@hollisvane.example" ariaLabel="Copy email address" className="size-11" />
</div>
<div className="mt-20 grid gap-16 lg:grid-cols-[1.4fr_1fr]">
{sent ? (
<div role="status" className="bg-chart-1 h-fit p-10 text-[var(--studio-on-accent)]">
<Check className="size-12" strokeWidth={2.5} aria-hidden="true" />
<p className={cn("mt-6 text-5xl", studioDisplay)}>Thanks, {sent}.</p>
<p className="mt-4 max-w-md text-xl text-pretty">We read every brief. One of us will reply within two working days.</p>
</div>
) : (
<form onSubmit={submit} noValidate className="space-y-8">
<div className="grid gap-8 sm:grid-cols-2">
<div><label htmlFor="sc-name" className="text-sm font-bold uppercase">Name</label><input id="sc-name" name="name" autoComplete="name" aria-invalid={!!errors.name} aria-describedby={errors.name ? "sc-name-err" : undefined} className={field} />{errors.name && <p id="sc-name-err" className="text-destructive mt-2 text-sm font-semibold">{errors.name}</p>}</div>
<div><label htmlFor="sc-email" className="text-sm font-bold uppercase">Email</label><input id="sc-email" name="email" type="email" autoComplete="email" aria-invalid={!!errors.email} aria-describedby={errors.email ? "sc-email-err" : undefined} className={field} />{errors.email && <p id="sc-email-err" className="text-destructive mt-2 text-sm font-semibold">{errors.email}</p>}</div>
</div>
<fieldset><legend className="text-sm font-bold uppercase">Budget</legend>
<div className="mt-3 flex flex-wrap gap-2">{budgets.map((b) => <label key={b} className={cn("focus-within:ring-ring/50 cursor-pointer rounded-full border-2 border-current px-5 py-2.5 font-semibold transition-colors focus-within:ring-[3px]", budget === b ? "bg-foreground text-background" : "hover:bg-chart-3 hover:text-[var(--studio-on-accent)]")}><input type="radio" name="budget" value={b} checked={budget === b} onChange={() => setBudget(b)} className="sr-only" />{b}</label>)}</div>
</fieldset>
<div><label htmlFor="sc-brief" className="text-sm font-bold uppercase">The brief</label><textarea id="sc-brief" name="brief" rows={5} aria-invalid={!!errors.brief} aria-describedby={errors.brief ? "sc-brief-err" : undefined} className={cn(field, "h-auto py-3")} />{errors.brief && <p id="sc-brief-err" className="text-destructive mt-2 text-sm font-semibold">{errors.brief}</p>}</div>
<button type="submit" className="bg-foreground text-background focus-visible:ring-ring/50 inline-flex h-16 items-center rounded-full px-10 text-lg font-bold outline-none transition-transform hover:-translate-y-0.5 focus-visible:ring-[3px] motion-reduce:transition-none">Send the brief</button>
</form>
)}
<aside aria-label="Offices" className="space-y-10">
{[["Lisbon", "Rua da Boavista 12, 1200-066", "Mon to Fri, 9 to 18"], ["Berlin", "Oranienstraße 164, 10969", "Mon to Fri, 9 to 18"]].map(([c, a, t]) => <div key={c} className="border-t-2 pt-4"><h2 className={cn("text-4xl", studioDisplay)}>{c}</h2><p className="mt-3 text-lg">{a}</p><p className="text-muted-foreground text-sm" style={mono}>{t}</p></div>)}
<p className="text-muted-foreground text-sm text-pretty">Looking for a job instead? We hire one or two people a year, usually after seeing their work somewhere else. Send it anyway.</p>
</aside>
</div>
</main>
</StudioShell>
)
}
export { StudioContact, type StudioContactProps }// Ballmac UI: Studio template route. https://ui.ballmac.com/templates/template-studio
import type { Metadata } from "next"
import { StudioHome } from "@/components/ballmac/templates/studio/studio-home"
export const metadata: Metadata = {
title: "Hollis & Vane: brands that move people",
description: "An independent branding and digital studio in Lisbon and Berlin.",
}
export default function Page() {
return <StudioHome />
}// Ballmac UI: Studio template route. https://ui.ballmac.com/templates/template-studio
import type { Metadata } from "next"
import { StudioWork } from "@/components/ballmac/templates/studio/studio-work"
export const metadata: Metadata = {
title: "Work · Hollis & Vane",
description: "Identities, websites and campaigns.",
}
export default function Page() {
return <StudioWork />
}// Ballmac UI: Studio template route. https://ui.ballmac.com/templates/template-studio
import type { Metadata } from "next"
import { StudioProject } from "@/components/ballmac/templates/studio/studio-project"
export const metadata: Metadata = {
title: "North Coast Rail · Hollis & Vane",
description: "A railway identity that moves.",
}
// One layout for every project. Load the project by params.slug from your data when you add more.
export default function Page() {
return <StudioProject />
}// Ballmac UI: Studio template route. https://ui.ballmac.com/templates/template-studio
import type { Metadata } from "next"
import { StudioServices } from "@/components/ballmac/templates/studio/studio-services"
export const metadata: Metadata = {
title: "Services · Hollis & Vane",
description: "Brand identity, digital design, campaigns and a retained studio.",
}
export default function Page() {
return <StudioServices />
}// Ballmac UI: Studio template route. https://ui.ballmac.com/templates/template-studio
import type { Metadata } from "next"
import { StudioContact } from "@/components/ballmac/templates/studio/studio-contact"
export const metadata: Metadata = {
title: "Contact · Hollis & Vane",
description: "Send us a brief. We reply within two working days.",
}
export default function Page() {
return <StudioContact />
}Installation
$ pnpm dlx shadcn@latest add @ballmac/template-studioInstall the dependencies.
$ pnpm add lucide-react motion@^12Add the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/copy-button @ballmac/marquee @ballmac/mediaCopy the source into your project.
components/ballmac/templates/studio/studio-fonts.ts// Ballmac UI: Studio template fonts. https://ui.ballmac.com/templates/template-studio import { Archivo, Geist_Mono } from "next/font/google" /** Archivo has a width axis, so headlines can run wide and heavy while body copy stays normal. */ export const studioSans = Archivo({ variable: "--studio-sans", subsets: ["latin"], axes: ["wdth"], display: "swap", preload: false }) export const studioMono = Geist_Mono({ variable: "--studio-mono", subsets: ["latin"], display: "swap", preload: false })components/ballmac/templates/studio/studio-data.tsimport type { MediaSource } from "@/components/ballmac/media" // Ballmac UI: Studio sample content. https://ui.ballmac.com/templates/template-studio export type StudioProject = { slug: string; name: string; client: string; year: string; discipline: "Brand" | "Digital" | "Campaign"; line: string; art: number; image?: MediaSource; imageAlt?: string } export const projects: StudioProject[] = [ { slug: "north-coast", name: "North Coast Rail", client: "North Coast", year: "2026", discipline: "Brand", line: "A railway identity that moves.", art: 0 }, { slug: "oat-and-ember", name: "Oat & Ember", client: "Oat & Ember", year: "2026", discipline: "Campaign", line: "A launch that sold out in nine days.", art: 1 }, { slug: "meridian", name: "Meridian Bank", client: "Meridian", year: "2025", discipline: "Digital", line: "Banking without the small print.", art: 2 }, { slug: "tidal", name: "Tidal Festival", client: "Tidal", year: "2025", discipline: "Brand", line: "Three days, one living identity.", art: 3 }, { slug: "pilot", name: "Pilot Coffee", client: "Pilot", year: "2025", discipline: "Campaign", line: "Packaging that earned its shelf.", art: 4 }, { slug: "alder", name: "Alder & Co", client: "Alder", year: "2024", discipline: "Digital", line: "A furniture shop built on stories.", art: 5 }, ] export const services = [ { n: "01", title: "Brand identity", text: "Strategy, naming, logos, typefaces and the system that holds them together. We leave you with a brand people can actually use on a Tuesday.", items: ["Positioning", "Naming", "Visual identity", "Brand guidelines"], from: "From €45k" }, { n: "02", title: "Digital design", text: "Websites and products with a point of view. Designed in the browser, built with your team, and measured after launch.", items: ["Websites", "Product design", "Design systems", "Motion"], from: "From €60k" }, { n: "03", title: "Campaigns", text: "Ideas that travel. We direct, shoot and launch across print, social, film and the street.", items: ["Concept", "Art direction", "Film and photography", "Launch"], from: "From €35k" }, { n: "04", title: "Retained studio", text: "A senior team on call for a monthly fee. For brands that ship constantly and want one look across everything.", items: ["Monthly capacity", "Quarterly roadmap", "Priority access"], from: "From €12k / month" }, ] export const process = [ ["Listen", "Two weeks of interviews, audits and arguments. We write down what is true before we make anything pretty."], ["Make", "Small, fast, ugly first. You see work every week, not a reveal at the end."], ["Launch", "We stay until it is live, then measure it. The work isn't finished when it is delivered."], ]components/ballmac/templates/studio/studio-theme.tsx// Ballmac UI: Studio template shell. https://ui.ballmac.com/templates/template-studio "use client" import * as React from "react" import { ArrowUpRight, Menu, X } from "lucide-react" import { studioMono, studioSans } from "@/components/ballmac/templates/studio/studio-fonts" import { cn } from "@/lib/utils" import { Media, type MediaSource } from "@/components/ballmac/media" type StudioPage = "home" | "work" | "project" | "services" | "contact" type StudioHrefs = Record<StudioPage, string> const defaultHrefs: StudioHrefs = { home: "/studio", work: "/studio/work", project: "/studio/work/north-coast", services: "/studio/services", contact: "/studio/contact" } /** Studio's palette: stark white and black with a hot orange, plus electric blue, acid yellow, pink and mint for art. */ const studioCss = ` .studio-theme,body:not(:has([data-gallery])):has(.studio-theme){--background:oklch(0.99 0 0);--foreground:oklch(0.12 0 0);--card:oklch(1 0 0);--card-foreground:oklch(0.12 0 0);--popover:oklch(1 0 0);--popover-foreground:oklch(0.12 0 0);--primary:oklch(0.12 0 0);--primary-foreground:oklch(0.99 0 0);--secondary:oklch(0.955 0 0);--secondary-foreground:oklch(0.12 0 0);--muted:oklch(0.955 0 0);--muted-foreground:oklch(0.44 0 0);--accent:oklch(0.93 0.02 40);--accent-foreground:oklch(0.12 0 0);--border:oklch(0.12 0 0 / 16%);--input:oklch(0.12 0 0 / 28%);--ring:oklch(0.12 0 0);--surface:oklch(0.965 0 0);--destructive:oklch(0.52 0.24 27);--chart-1:oklch(0.68 0.22 40);--chart-2:oklch(0.52 0.24 265);--chart-3:oklch(0.92 0.18 100);--chart-4:oklch(0.72 0.22 350);--chart-5:oklch(0.85 0.13 170);--studio-on-accent:oklch(0.12 0 0);--radius:0.25rem} .dark .studio-theme,.dark body:not(:has([data-gallery])):has(.studio-theme){--background:oklch(0.12 0 0);--foreground:oklch(0.97 0 0);--card:oklch(0.16 0 0);--card-foreground:oklch(0.97 0 0);--popover:oklch(0.18 0 0);--popover-foreground:oklch(0.97 0 0);--primary:oklch(0.97 0 0);--primary-foreground:oklch(0.12 0 0);--secondary:oklch(0.2 0 0);--secondary-foreground:oklch(0.97 0 0);--muted:oklch(0.2 0 0);--muted-foreground:oklch(0.72 0 0);--accent:oklch(0.24 0.02 40);--accent-foreground:oklch(0.97 0 0);--border:oklch(1 0 0 / 16%);--input:oklch(1 0 0 / 28%);--ring:oklch(0.97 0 0);--surface:oklch(0.15 0 0);--destructive:oklch(0.7 0.2 27);--chart-1:oklch(0.7 0.21 40);--chart-2:oklch(0.52 0.24 265);--chart-3:oklch(0.92 0.18 100);--chart-4:oklch(0.74 0.2 350);--chart-5:oklch(0.85 0.13 170);--studio-on-accent:oklch(0.12 0 0)} body:not(:has([data-gallery])):has(.studio-theme){font-family:var(--studio-sans),ui-sans-serif,system-ui,sans-serif} ` /** Wide, heavy headline settings for Archivo. */ const display = "[font-stretch:112%] font-extrabold uppercase tracking-[-0.03em] leading-[0.88]" /** A painted poster: bold shapes in the accent colours, standing in for project imagery. */ function StudioArt({ 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-1 absolute inset-0" /><div className="bg-foreground absolute -bottom-[20%] -start-[10%] w-[80%] aspect-square rounded-full" /><div className="bg-chart-3 absolute top-[12%] end-[12%] w-[26%] aspect-square rounded-full" /></>)} {v === 1 && (<><div className="bg-chart-2 absolute inset-0" /><div className="bg-chart-3 absolute inset-y-0 start-[18%] w-[22%]" /><div className="bg-chart-4 absolute inset-y-0 start-[48%] w-[12%]" /><div className="bg-primary-foreground absolute top-[18%] end-[10%] h-[64%] w-[16%] rounded-full" /></>)} {v === 2 && (<><div className="bg-foreground absolute inset-0" />{[0, 1, 2, 3, 4].map((i) => <div key={i} className="border-primary-foreground absolute inset-x-[10%] rounded-full border-[3px]" style={{ top: `${10 + i * 12}%`, height: `${60 - i * 8}%`, opacity: 1 - i * 0.16 }} />)}<div className="bg-chart-1 absolute end-[14%] bottom-[14%] w-[20%] aspect-square rounded-full" /></>)} {v === 3 && (<><div className="bg-chart-4 absolute inset-0" /><div className="absolute inset-[8%] grid grid-cols-4 grid-rows-3 gap-[2%]">{Array.from({ length: 12 }, (_, i) => <div key={i} className={cn("rounded-full", i % 5 === 0 ? "bg-chart-3" : i % 3 === 0 ? "bg-foreground" : "bg-primary-foreground")} />)}</div></>)} {v === 4 && (<><div className="bg-chart-3 absolute inset-0" /><div className="bg-foreground absolute top-[16%] start-[22%] h-[68%] w-[56%] rounded-t-[999px]" /><div className="bg-chart-1 absolute bottom-[16%] start-[34%] w-[32%] aspect-square rounded-full" /></>)} {v === 5 && (<><div className="bg-chart-5 absolute inset-0" /><div className="bg-foreground absolute inset-x-0 bottom-0 h-[38%]" /><div className="bg-chart-2 absolute bottom-[38%] start-[12%] h-[34%] w-[28%]" /><div className="bg-chart-1 absolute end-[14%] bottom-[38%] h-[52%] w-[22%] rounded-t-full" /></>)} </div> ) } const links: { key: StudioPage; label: string }[] = [ { key: "work", label: "Work" }, { key: "services", label: "Services" }, { key: "contact", label: "Contact" }, ] type StudioShellProps = React.ComponentProps<"div"> & { /** The page being shown, so its nav link is marked current. */ page: StudioPage /** Override where pages live (used by previews). */ hrefs?: Partial<StudioHrefs> } /** Studio's frame: a heavy wordmark, a plain nav, and a footer that is mostly the studio's name. */ function StudioShell({ page, hrefs: overrides, className, style, children, ...props }: StudioShellProps) { const hrefs = { ...defaultHrefs, ...overrides } const [open, setOpen] = React.useState(false) React.useEffect(() => { const classes = [studioSans.variable, studioMono.variable].filter(Boolean) document.body.classList.add(...classes) return () => document.body.classList.remove(...classes) }, []) const active = page === "project" ? "work" : page return ( <div data-slot="studio" className={cn("studio-theme bg-background text-foreground relative min-h-dvh overflow-x-clip", studioSans.variable, studioMono.variable, className)} style={{ fontFamily: "var(--studio-sans), ui-sans-serif, system-ui, sans-serif", ...style }} {...props} > <style>{studioCss}</style> <header className="bg-background/90 sticky top-0 z-40 border-b backdrop-blur-lg"> <div className="mx-auto flex h-14 max-w-[100rem] items-center justify-between px-4 sm:px-8"> <a href={hrefs.home} className={cn("focus-visible:ring-ring/50 rounded text-xl outline-none focus-visible:ring-[3px]", display)}>Hollis<span className="text-chart-1">&</span>Vane</a> <nav aria-label="Main" className="hidden items-center gap-8 md:flex"> {links.map((l) => <a key={l.key} href={hrefs[l.key]} aria-current={active === l.key ? "page" : undefined} className="hover:text-chart-1 aria-[current=page]:underline decoration-chart-1 focus-visible:ring-ring/50 rounded text-sm font-semibold tracking-wide uppercase underline-offset-[10px] outline-none transition-colors aria-[current=page]:decoration-[3px] focus-visible:ring-[3px]">{l.label}</a>)} </nav> <p className="text-muted-foreground hidden text-xs lg:block" style={{ fontFamily: "var(--studio-mono)" }}>Lisbon · Berlin</p> <button type="button" aria-label={open ? "Close menu" : "Open menu"} aria-expanded={open} aria-controls="studio-mobile-menu" onClick={() => setOpen((v) => !v)} className="hover:bg-accent focus-visible:ring-ring/50 inline-flex size-10 items-center justify-center rounded 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="studio-mobile-menu" aria-label="Mobile" className="border-t px-4 py-3 md:hidden"> {links.map((l) => <a key={l.key} href={hrefs[l.key]} className={cn("hover:text-chart-1 block py-2.5 text-3xl", display)}>{l.label}</a>)} </nav> )} </header> {children} <footer className="bg-foreground text-background mt-24"> <div className="mx-auto max-w-[100rem] px-4 pt-16 pb-8 sm:px-8"> <div className="flex flex-wrap items-end justify-between gap-8"> <p className="max-w-md text-xl text-pretty opacity-80">New project, new idea, or just a question? We reply to every email within two working days.</p> <a href={hrefs.contact} className="bg-chart-1 text-[var(--studio-on-accent)] focus-visible:ring-ring inline-flex h-14 items-center gap-2 rounded-full px-8 text-lg font-bold outline-none transition-transform hover:-translate-y-0.5 focus-visible:ring-[3px] motion-reduce:transition-none">Start a project <ArrowUpRight className="size-5 rtl:-scale-x-100" aria-hidden="true" /></a> </div> <p aria-hidden="true" className={cn("mt-14 text-[clamp(3.4rem,17.5vw,19rem)] whitespace-nowrap", display)}>Hollis&Vane</p> <div className="mt-8 flex flex-wrap justify-between gap-4 border-t border-current/25 pt-6 text-sm opacity-70"> <p>© 2026 Hollis & Vane Studio Ltd.</p> <ul className="flex gap-6">{["Instagram", "Behance", "LinkedIn", "Newsletter"].map((l) => <li key={l}><a href="#" className="hover:underline">{l}</a></li>)}</ul> </div> </div> </footer> </div> ) } export { StudioArt, StudioShell, defaultHrefs as studioDefaultHrefs, display as studioDisplay, type StudioHrefs, type StudioPage, type StudioShellProps }components/ballmac/templates/studio/studio-home.tsx// Ballmac UI: Studio home page. https://ui.ballmac.com/templates/template-studio "use client" import * as React from "react" import { ArrowUpRight } from "lucide-react" import { useReducedMotion } from "motion/react" import { Marquee } from "@/components/ballmac/marquee" import { process, projects, services } from "@/components/ballmac/templates/studio/studio-data" import { StudioArt, StudioShell, studioDisplay, type StudioHrefs } from "@/components/ballmac/templates/studio/studio-theme" import { cn } from "@/lib/utils" const mono = { fontFamily: "var(--studio-mono)" } as const /** A list of projects. Hover or focus a row and a poster follows the pointer (or sits beside the row for keyboard users). */ function WorkReel({ href }: { href: string }) { const reduce = useReducedMotion() const [active, setActive] = React.useState<number | null>(null) const [mode, setMode] = React.useState<"pointer" | "focus">("pointer") const box = React.useRef<HTMLDivElement>(null) const card = React.useRef<HTMLDivElement>(null) function move(e: React.PointerEvent) { const r = box.current?.getBoundingClientRect() if (!r || !card.current || reduce) return card.current.style.transform = `translate(${e.clientX - r.left + 28}px, ${e.clientY - r.top - 120}px)` } const shown = active !== null ? projects[active] : null return ( <div ref={box} className="relative" onPointerMove={move} onPointerLeave={() => setActive(null)}> <ul className="border-t-2 border-current"> {projects.map((p, i) => ( <li key={p.slug} className="border-b"> <a href={href} onPointerEnter={() => { setMode("pointer"); setActive(i) }} onFocus={() => { setMode("focus"); setActive(i) }} onBlur={() => setActive(null)} className="hover:bg-chart-1 hover:text-[var(--studio-on-accent)] focus-visible:bg-chart-1 focus-visible:text-[var(--studio-on-accent)] group grid grid-cols-[1fr_auto] items-baseline gap-4 px-2 py-6 outline-none transition-colors sm:grid-cols-[3rem_1fr_10rem_5rem] sm:px-4 motion-reduce:transition-none" > <span className="text-muted-foreground group-hover:text-[var(--studio-on-accent)] group-focus-visible:text-[var(--studio-on-accent)] hidden text-sm sm:block" style={mono}>{String(i + 1).padStart(2, "0")}</span> <span className={cn("text-[clamp(2rem,6.4vw,5.5rem)] text-balance", studioDisplay)}>{p.name}</span> <span className="group-hover:text-[var(--studio-on-accent)] group-focus-visible:text-[var(--studio-on-accent)] text-muted-foreground hidden text-sm sm:block" style={mono}>{p.discipline}</span> <span className="group-hover:text-[var(--studio-on-accent)] group-focus-visible:text-[var(--studio-on-accent)] text-muted-foreground text-end text-sm tabular-nums" style={mono}>{p.year}</span> </a> </li> ))} </ul> <div ref={card} aria-hidden="true" className={cn("pointer-events-none absolute top-0 start-0 z-10 hidden w-72 will-change-transform md:block", shown ? "opacity-100" : "opacity-0", "transition-opacity duration-200 motion-reduce:transition-none")} style={mode === "focus" || reduce ? { transform: `translate(calc(100% - 20rem), ${(active ?? 0) * 7.2}rem)` } : undefined} > {shown && (<div className="border-foreground bg-background overflow-hidden rounded border-2 shadow-2xl"><StudioArt variant={shown.art} image={shown.image} imageAlt={shown.imageAlt} /><p className="border-t-2 px-3 py-2 text-sm font-semibold">{shown.line}</p></div>)} </div> </div> ) } type StudioHomeProps = React.ComponentProps<"div"> & { hrefs?: Partial<StudioHrefs> } /** The Studio home page: a huge statement, a hover-reveal work reel, services, process and a loud call to action. */ function StudioHome({ hrefs, ...props }: StudioHomeProps) { const h = { work: hrefs?.work ?? "/studio/work", project: hrefs?.project ?? "/studio/work/north-coast", services: hrefs?.services ?? "/studio/services", contact: hrefs?.contact ?? "/studio/contact" } return ( <StudioShell page="home" hrefs={hrefs} {...props}> <main> <section className="mx-auto max-w-[100rem] px-4 pt-10 pb-12 sm:px-8 sm:pt-16"> <p className="text-muted-foreground text-sm" style={mono}>Independent branding & digital studio · est. 2016</p> <h1 className={cn("mt-6 text-[clamp(2.8rem,9.2vw,10rem)]", studioDisplay)}> <span className="block">We make</span> <span className="block">brands that</span> <span className="block"><span className="bg-chart-1 inline-block px-[0.12em] text-[var(--studio-on-accent)]">move</span> people.</span> </h1> <div className="mt-12 grid gap-8 sm:grid-cols-[1fr_auto] sm:items-end"> <p className="max-w-xl text-xl text-pretty sm:text-2xl">Hollis & Vane is a 14-person studio in Lisbon and Berlin. We design identities, websites and campaigns for people who would rather be remembered than liked.</p> <a href={h.work} className="bg-foreground text-background focus-visible:ring-ring/50 inline-flex h-14 items-center gap-2 rounded-full px-8 text-lg font-bold outline-none transition-transform hover:-translate-y-0.5 focus-visible:ring-[3px] motion-reduce:transition-none">See the work <ArrowUpRight className="size-5 rtl:-scale-x-100" aria-hidden="true" /></a> </div> </section> <section aria-label="Clients" className="border-foreground bg-foreground text-background border-y-2 py-5"> <Marquee speed={60} gap={56} pauseOnHover> {["North Coast", "Oat & Ember", "Meridian", "Tidal", "Pilot", "Alder & Co", "Fernhill", "Kestrel"].map((c) => <span key={c} className={cn("flex items-center gap-14 text-4xl sm:text-5xl", studioDisplay)}>{c}<span className="bg-chart-1 size-4 rounded-full" aria-hidden="true" /></span>)} </Marquee> </section> <section id="work" aria-labelledby="st-work" className="mx-auto max-w-[100rem] px-4 py-24 sm:px-8"> <div className="mb-10 flex items-end justify-between gap-4"><h2 id="st-work" className={cn("text-[clamp(2.4rem,7vw,6rem)]", studioDisplay)}>Selected work</h2><p className="text-muted-foreground hidden text-sm sm:block" style={mono}>Hover a row</p></div> <WorkReel href={h.project} /> </section> <section aria-labelledby="st-services" className="border-t-2 border-current"> <div className="mx-auto grid max-w-[100rem] gap-12 px-4 py-24 sm:px-8 lg:grid-cols-[1fr_2fr]"> <div><h2 id="st-services" className={cn("text-[clamp(2.4rem,6vw,5rem)]", studioDisplay)}>What we do</h2><a href={h.services} className="mt-6 inline-flex items-center gap-2 font-bold underline decoration-2 underline-offset-8">All services <ArrowUpRight className="size-4 rtl:-scale-x-100" aria-hidden="true" /></a></div> <ul className="divide-y-2 border-y-2"> {services.map((s) => ( <li key={s.n} className="grid gap-3 py-8 sm:grid-cols-[4rem_1fr]"> <span className="bg-chart-1 h-fit w-fit px-2 py-0.5 text-lg font-bold text-[var(--studio-on-accent)]" style={mono}>{s.n}</span> <div><h3 className={cn("text-3xl", studioDisplay)}>{s.title}</h3><p className="text-muted-foreground mt-3 max-w-xl text-pretty">{s.text}</p></div> </li> ))} </ul> </div> </section> <section aria-labelledby="st-process" className="bg-chart-2 text-white"> <div className="mx-auto max-w-[100rem] px-4 py-24 sm:px-8"> <h2 id="st-process" className={cn("text-[clamp(2.4rem,6vw,5rem)]", studioDisplay)}>How we work</h2> <ol className="mt-14 grid gap-px bg-white/30 md:grid-cols-3"> {process.map(([t, d], i) => ( <li key={t} className="bg-chart-2 p-8 md:p-10"> <span className="text-sm" style={mono}>Step {i + 1}</span> <h3 className={cn("mt-4 text-5xl", studioDisplay)}>{t}</h3> <p className="mt-5 text-lg text-pretty opacity-90">{d}</p> </li> ))} </ol> </div> </section> <section className="mx-auto max-w-[100rem] px-4 py-24 sm:px-8"> <blockquote className={cn("max-w-6xl text-[clamp(1.8rem,4.6vw,4rem)] leading-[1.05] normal-case", studioDisplay.replace("uppercase", ""))}>“They didn’t give us a logo. They gave us a way of behaving, and now the whole company does it.”</blockquote> <p className="text-muted-foreground mt-8 text-sm" style={mono}>Anaïs Dupont · CMO, North Coast Rail</p> </section> <section className="bg-chart-1 text-[var(--studio-on-accent)]"> <a href={h.contact} className="group focus-visible:ring-ring mx-auto flex max-w-[100rem] items-center justify-between gap-6 px-4 py-16 outline-none focus-visible:ring-[3px] focus-visible:ring-inset sm:px-8 sm:py-24"> <span className={cn("text-[clamp(3rem,11vw,10rem)]", studioDisplay)}>Let’s talk</span> <ArrowUpRight className="size-[clamp(3rem,10vw,9rem)] shrink-0 transition-transform duration-300 group-hover:translate-x-2 group-hover:-translate-y-2 motion-reduce:transition-none rtl:-scale-x-100 rtl:group-hover:-translate-x-2" strokeWidth={1.5} aria-hidden="true" /> </a> </section> </main> </StudioShell> ) } export { StudioHome, type StudioHomeProps }components/ballmac/templates/studio/studio-work.tsx// Ballmac UI: Studio work page. https://ui.ballmac.com/templates/template-studio "use client" import * as React from "react" import { LayoutGrid, List } from "lucide-react" import { projects } from "@/components/ballmac/templates/studio/studio-data" import { StudioArt, StudioShell, studioDisplay, type StudioHrefs } from "@/components/ballmac/templates/studio/studio-theme" import { cn } from "@/lib/utils" const mono = { fontFamily: "var(--studio-mono)" } as const type Filter = "All" | "Brand" | "Digital" | "Campaign" const filters: Filter[] = ["All", "Brand", "Digital", "Campaign"] type StudioWorkProps = React.ComponentProps<"div"> & { hrefs?: Partial<StudioHrefs> } /** The Studio work index: every project as a table or a poster grid, filtered by discipline. */ function StudioWork({ hrefs, ...props }: StudioWorkProps) { const [filter, setFilter] = React.useState<Filter>("All") const [view, setView] = React.useState<"list" | "grid">("grid") const shown = projects.filter((p) => filter === "All" || p.discipline === filter) const href = hrefs?.project ?? "/studio/work/north-coast" return ( <StudioShell page="work" hrefs={hrefs} {...props}> <main className="mx-auto max-w-[100rem] px-4 pt-10 sm:px-8 sm:pt-16"> <h1 className={cn("text-[clamp(3rem,13vw,13rem)]", studioDisplay)}>Work <span className="text-muted-foreground text-[0.22em] align-top tabular-nums" style={mono}>({shown.length})</span></h1> <div className="mt-10 flex flex-wrap items-center gap-3 border-y-2 py-4"> <div role="group" aria-label="Filter by discipline" className="flex flex-wrap gap-2"> {filters.map((f) => <button key={f} type="button" aria-pressed={filter === f} onClick={() => setFilter(f)} className={cn("focus-visible:ring-ring/50 h-11 rounded-full border-2 border-current px-5 text-sm font-bold tracking-wide uppercase outline-none transition-colors focus-visible:ring-[3px]", filter === f ? "bg-foreground text-background" : "hover:bg-chart-1 hover:text-[var(--studio-on-accent)]")}>{f}</button>)} </div> <div role="group" aria-label="Layout" className="ms-auto flex gap-1"> {([["grid", LayoutGrid, "Grid"], ["list", List, "List"]] as const).map(([v, Icon, l]) => <button key={v} type="button" aria-pressed={view === v} aria-label={`${l} view`} onClick={() => setView(v)} className={cn("focus-visible:ring-ring/50 inline-flex size-11 items-center justify-center rounded-full border-2 outline-none focus-visible:ring-[3px]", view === v ? "bg-foreground text-background border-transparent" : "hover:bg-accent border-current")}><Icon className="size-4" aria-hidden="true" /></button>)} </div> </div> <p className="sr-only" role="status">{shown.length} projects shown</p> {view === "grid" ? ( <ul className="mt-10 grid gap-x-6 gap-y-14 sm:grid-cols-2 lg:grid-cols-3"> {shown.map((p) => ( <li key={p.slug}> <a href={href} className="group focus-visible:ring-ring/50 block outline-none focus-visible:ring-[3px] focus-visible:ring-offset-4 focus-visible:ring-offset-background"> <div className="border-foreground overflow-hidden border-2"><StudioArt variant={p.art} image={p.image} imageAlt={p.imageAlt} className="transition-transform duration-500 group-hover:scale-105 group-focus-visible:scale-105 motion-reduce:transition-none" /></div> <div className="mt-4 flex items-baseline justify-between gap-3"><h2 className={cn("text-3xl group-hover:underline decoration-chart-1 decoration-4 underline-offset-4", studioDisplay)}>{p.name}</h2><span className="text-muted-foreground text-sm tabular-nums" style={mono}>{p.year}</span></div> <p className="text-muted-foreground mt-1 text-sm" style={mono}>{p.discipline} · {p.line}</p> </a> </li> ))} </ul> ) : ( <div className="mt-6 overflow-x-auto"> <table className="w-full min-w-[34rem] text-start"> <caption className="sr-only">All projects</caption> <thead><tr className="text-muted-foreground border-b text-xs uppercase" style={mono}><th scope="col" className="py-3 font-normal">Project</th><th scope="col" className="py-3 font-normal">Discipline</th><th scope="col" className="py-3 font-normal">Client</th><th scope="col" className="py-3 text-end font-normal">Year</th></tr></thead> <tbody> {shown.map((p) => ( <tr key={p.slug} className="hover:bg-chart-1 hover:text-[var(--studio-on-accent)] border-b transition-colors motion-reduce:transition-none"> <th scope="row" className="py-5 pe-4"><a href={href} className={cn("focus-visible:ring-ring/50 rounded text-2xl outline-none focus-visible:ring-[3px] sm:text-4xl", studioDisplay)}>{p.name}</a></th> <td className="py-5 text-sm" style={mono}>{p.discipline}</td> <td className="py-5 text-sm" style={mono}>{p.client}</td> <td className="py-5 text-end text-sm tabular-nums" style={mono}>{p.year}</td> </tr> ))} </tbody> </table> </div> )} </main> </StudioShell> ) } export { StudioWork, type StudioWorkProps }components/ballmac/templates/studio/studio-project.tsx// Ballmac UI: Studio project page. https://ui.ballmac.com/templates/template-studio "use client" import * as React from "react" import { ArrowUpRight } from "lucide-react" import { projects } from "@/components/ballmac/templates/studio/studio-data" import { StudioArt, StudioShell, studioDisplay, type StudioHrefs } from "@/components/ballmac/templates/studio/studio-theme" import { cn } from "@/lib/utils" const mono = { fontFamily: "var(--studio-mono)" } as const const facts = [["Client", "North Coast Rail"], ["Year", "2026"], ["Services", "Identity, signage, motion, website"], ["Team", "6 people, 11 months"]] const credits = [["Creative direction", "Inês Hollis"], ["Design lead", "Jonas Vane"], ["Motion", "Mei Tanaka"], ["Development", "Colm Brennan"], ["Strategy", "Amara Okafor"]] type StudioProjectProps = React.ComponentProps<"div"> & { hrefs?: Partial<StudioHrefs> } /** A Studio project page: a full-bleed poster, a fact line, big imagery in two rhythms, a pull quote and credits. */ function StudioProject({ hrefs, ...props }: StudioProjectProps) { const next = projects[1] return ( <StudioShell page="project" hrefs={hrefs} {...props}> <main> <header className="mx-auto max-w-[100rem] px-4 pt-10 sm:px-8 sm:pt-16"> <p className="text-muted-foreground text-sm" style={mono}><a href={hrefs?.work ?? "/studio/work"} className="underline-offset-4 hover:underline">Work</a> / Brand</p> <h1 className={cn("mt-4 text-[clamp(3rem,12vw,12rem)]", studioDisplay)}>North Coast Rail</h1> <p className="mt-6 max-w-3xl text-2xl text-pretty sm:text-3xl">A railway identity that moves: from a logo built from a single line to 400 trains that carry it.</p> </header> <StudioArt variant={0} className="border-foreground mt-12 aspect-[16/8] border-y-2" /> <dl className="mx-auto grid max-w-[100rem] gap-6 px-4 py-10 sm:grid-cols-2 sm:px-8 lg:grid-cols-4"> {facts.map(([k, v]) => <div key={k}><dt className="text-muted-foreground text-xs uppercase" style={mono}>{k}</dt><dd className="mt-2 text-lg font-semibold">{v}</dd></div>)} </dl> <section className="mx-auto max-w-[100rem] px-4 py-12 sm:px-8"> <div className="grid gap-12 lg:grid-cols-[1fr_2fr]"> <h2 className={cn("text-4xl", studioDisplay)}>The idea</h2> <p className="max-w-3xl text-2xl leading-snug text-pretty sm:text-3xl">A railway is one long line with many stops. We drew the identity the same way: a single continuous stroke that bends into the letters, the map and the livery. It is simple enough to paint on a train and distinctive enough to recognise from the platform.</p> </div> </section> <section className="mx-auto max-w-[100rem] space-y-4 px-4 sm:px-8"> <div className="grid gap-4 sm:grid-cols-2"><StudioArt variant={1} className="border-foreground border-2" /><StudioArt variant={4} className="border-foreground border-2" /></div> <StudioArt variant={3} className="border-foreground aspect-[16/7] border-2" /> </section> <section className="bg-chart-1 mt-20 text-[var(--studio-on-accent)]"> <figure className="mx-auto max-w-[100rem] px-4 py-20 sm:px-8 sm:py-28"> <blockquote className={cn("max-w-6xl text-[clamp(2rem,5.6vw,5rem)]", studioDisplay)}>“Passengers started photographing the trains. We have never had that before.”</blockquote> <figcaption className="mt-8 text-sm" style={mono}>Anaïs Dupont, CMO, North Coast Rail</figcaption> </figure> </section> <section aria-labelledby="sp-credits" className="mx-auto max-w-[100rem] px-4 py-20 sm:px-8"> <h2 id="sp-credits" className={cn("text-4xl", studioDisplay)}>Credits</h2> <dl className="mt-8 divide-y-2 border-y-2">{credits.map(([k, v]) => <div key={k} className="grid grid-cols-2 py-4 sm:grid-cols-[16rem_1fr]"><dt className="text-muted-foreground text-sm" style={mono}>{k}</dt><dd className="font-semibold">{v}</dd></div>)}</dl> </section> <a href={hrefs?.project ?? "/studio/work/oat-and-ember"} className="group border-foreground focus-visible:ring-ring/50 block border-t-2 outline-none focus-visible:ring-[3px] focus-visible:ring-inset"> <div className="mx-auto flex max-w-[100rem] items-center justify-between gap-6 px-4 py-14 sm:px-8"> <div><p className="text-muted-foreground text-sm" style={mono}>Next project</p><p className={cn("group-hover:text-chart-1 mt-3 text-[clamp(2.4rem,8vw,8rem)] transition-colors motion-reduce:transition-none", studioDisplay)}>{next.name}</p></div> <ArrowUpRight className="size-14 shrink-0 transition-transform group-hover:translate-x-2 group-hover:-translate-y-2 motion-reduce:transition-none rtl:-scale-x-100 rtl:group-hover:-translate-x-2" strokeWidth={1.5} aria-hidden="true" /> </div> </a> </main> </StudioShell> ) } export { StudioProject, type StudioProjectProps }components/ballmac/templates/studio/studio-services.tsx// Ballmac UI: Studio services page. https://ui.ballmac.com/templates/template-studio "use client" import * as React from "react" import { Minus, Plus } from "lucide-react" import { services } from "@/components/ballmac/templates/studio/studio-data" import { StudioShell, studioDisplay, type StudioHrefs } from "@/components/ballmac/templates/studio/studio-theme" import { cn } from "@/lib/utils" const mono = { fontFamily: "var(--studio-mono)" } as const type StudioServicesProps = React.ComponentProps<"div"> & { hrefs?: Partial<StudioHrefs> } /** The Studio services page: four numbered disciplines that open to show what's inside, with a starting price. */ function StudioServices({ hrefs, ...props }: StudioServicesProps) { const [open, setOpen] = React.useState<string | null>("01") return ( <StudioShell page="services" hrefs={hrefs} {...props}> <main className="mx-auto max-w-[100rem] px-4 pt-10 sm:px-8 sm:pt-16"> <h1 className={cn("text-[clamp(3rem,12vw,12rem)]", studioDisplay)}>Services</h1> <p className="mt-6 max-w-2xl text-2xl text-pretty">Four ways to work with us. Most projects start with one and grow into two.</p> <ul className="mt-14 border-t-2"> {services.map((s) => { const on = open === s.n return ( <li key={s.n} className="border-b-2"> <h2> <button type="button" aria-expanded={on} aria-controls={`ss-${s.n}`} onClick={() => setOpen(on ? null : s.n)} className="hover:bg-accent focus-visible:ring-ring/50 grid w-full grid-cols-[3.5rem_1fr_auto] items-center gap-4 px-2 py-7 text-start outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-inset sm:grid-cols-[6rem_1fr_auto] sm:px-4 motion-reduce:transition-none"> <span className="bg-chart-1 h-fit w-fit px-2 py-0.5 text-lg font-bold text-[var(--studio-on-accent)]" style={mono}>{s.n}</span> <span className={cn("text-[clamp(1.8rem,5vw,4.5rem)]", studioDisplay)}>{s.title}</span> {on ? <Minus className="size-8" aria-hidden="true" /> : <Plus className="size-8" aria-hidden="true" />} </button> </h2> <div id={`ss-${s.n}`} role="region" aria-labelledby={undefined} hidden={!on} className="grid gap-8 px-2 pb-10 sm:grid-cols-[6rem_1fr_16rem] sm:px-4"> <span aria-hidden="true" className="hidden sm:block" /> <div><p className="max-w-2xl text-xl text-pretty">{s.text}</p><ul className="mt-6 flex flex-wrap gap-2">{s.items.map((i) => <li key={i} className="rounded-full border-2 border-current px-4 py-1.5 text-sm font-semibold">{i}</li>)}</ul></div> <p className="bg-chart-3 h-fit rounded px-4 py-3 text-lg font-bold text-[var(--studio-on-accent)]" style={mono}>{s.from}</p> </div> </li> ) })} </ul> <section aria-labelledby="ss-faq" className="grid gap-10 py-24 lg:grid-cols-[1fr_2fr]"> <h2 id="ss-faq" className={cn("text-5xl", studioDisplay)}>Before you ask</h2> <dl className="divide-y-2 border-y-2"> {[["Do you work with startups?", "Yes, if the founders are the ones in the room. We take two a quarter."], ["How long does a project take?", "An identity takes 10 to 14 weeks. A website, 12 to 20. A campaign depends on the shoot."], ["Can you work with our team?", "Always. Most of our best work was built alongside in-house designers and developers."]].map(([q, a]) => <div key={q} className="py-6"><dt className="text-xl font-bold">{q}</dt><dd className="text-muted-foreground mt-2 max-w-xl text-pretty">{a}</dd></div>)} </dl> </section> </main> </StudioShell> ) } export { StudioServices, type StudioServicesProps }components/ballmac/templates/studio/studio-contact.tsx// Ballmac UI: Studio contact page. https://ui.ballmac.com/templates/template-studio "use client" import * as React from "react" import { Check } from "lucide-react" import { CopyButton } from "@/components/ballmac/copy-button" import { StudioShell, studioDisplay, type StudioHrefs } from "@/components/ballmac/templates/studio/studio-theme" import { cn } from "@/lib/utils" const mono = { fontFamily: "var(--studio-mono)" } as const const budgets = ["Under €30k", "€30–60k", "€60–120k", "€120k+"] const field = "bg-background focus-visible:ring-ring/50 mt-2 h-14 w-full border-2 border-current px-4 text-lg outline-none focus-visible:ring-[3px] aria-[invalid=true]:border-destructive" type StudioContactProps = React.ComponentProps<"div"> & { hrefs?: Partial<StudioHrefs> } /** The Studio contact page: a huge email, a project brief form with chips and inline errors, and the two offices. */ function StudioContact({ hrefs, ...props }: StudioContactProps) { const [budget, setBudget] = React.useState("") const [errors, setErrors] = React.useState<{ name?: string; email?: string; brief?: string }>({}) const [sent, setSent] = React.useState<string | null>(null) function submit(e: React.FormEvent<HTMLFormElement>) { e.preventDefault() const f = new FormData(e.currentTarget) const name = String(f.get("name") ?? "").trim() const email = String(f.get("email") ?? "").trim() const brief = String(f.get("brief") ?? "").trim() const next: typeof errors = {} if (!name) next.name = "Your name, please." if (!/^\S+@\S+\.\S+$/.test(email)) next.email = "That email doesn’t look right." if (brief.length < 20) next.brief = "Tell us a little more: at least a sentence." setErrors(next) const first = (["name", "email", "brief"] as const).find((k) => next[k]) if (first) { ;(e.currentTarget.elements.namedItem(first) as HTMLElement | null)?.focus() return } setSent(name.split(" ")[0]) } return ( <StudioShell page="contact" hrefs={hrefs} {...props}> <main className="mx-auto max-w-[100rem] px-4 pt-10 sm:px-8 sm:pt-16"> <h1 className={cn("text-[clamp(3rem,12vw,12rem)]", studioDisplay)}>Say hello</h1> <div className="mt-8 flex flex-wrap items-center gap-3"> <a href="mailto:hello@hollisvane.example" className="focus-visible:ring-ring/50 decoration-chart-1 rounded text-[clamp(1.4rem,4.4vw,3.6rem)] font-bold tracking-tight break-all underline decoration-4 underline-offset-8 outline-none focus-visible:ring-[3px]">hello@hollisvane.example</a> <CopyButton value="hello@hollisvane.example" ariaLabel="Copy email address" className="size-11" /> </div> <div className="mt-20 grid gap-16 lg:grid-cols-[1.4fr_1fr]"> {sent ? ( <div role="status" className="bg-chart-1 h-fit p-10 text-[var(--studio-on-accent)]"> <Check className="size-12" strokeWidth={2.5} aria-hidden="true" /> <p className={cn("mt-6 text-5xl", studioDisplay)}>Thanks, {sent}.</p> <p className="mt-4 max-w-md text-xl text-pretty">We read every brief. One of us will reply within two working days.</p> </div> ) : ( <form onSubmit={submit} noValidate className="space-y-8"> <div className="grid gap-8 sm:grid-cols-2"> <div><label htmlFor="sc-name" className="text-sm font-bold uppercase">Name</label><input id="sc-name" name="name" autoComplete="name" aria-invalid={!!errors.name} aria-describedby={errors.name ? "sc-name-err" : undefined} className={field} />{errors.name && <p id="sc-name-err" className="text-destructive mt-2 text-sm font-semibold">{errors.name}</p>}</div> <div><label htmlFor="sc-email" className="text-sm font-bold uppercase">Email</label><input id="sc-email" name="email" type="email" autoComplete="email" aria-invalid={!!errors.email} aria-describedby={errors.email ? "sc-email-err" : undefined} className={field} />{errors.email && <p id="sc-email-err" className="text-destructive mt-2 text-sm font-semibold">{errors.email}</p>}</div> </div> <fieldset><legend className="text-sm font-bold uppercase">Budget</legend> <div className="mt-3 flex flex-wrap gap-2">{budgets.map((b) => <label key={b} className={cn("focus-within:ring-ring/50 cursor-pointer rounded-full border-2 border-current px-5 py-2.5 font-semibold transition-colors focus-within:ring-[3px]", budget === b ? "bg-foreground text-background" : "hover:bg-chart-3 hover:text-[var(--studio-on-accent)]")}><input type="radio" name="budget" value={b} checked={budget === b} onChange={() => setBudget(b)} className="sr-only" />{b}</label>)}</div> </fieldset> <div><label htmlFor="sc-brief" className="text-sm font-bold uppercase">The brief</label><textarea id="sc-brief" name="brief" rows={5} aria-invalid={!!errors.brief} aria-describedby={errors.brief ? "sc-brief-err" : undefined} className={cn(field, "h-auto py-3")} />{errors.brief && <p id="sc-brief-err" className="text-destructive mt-2 text-sm font-semibold">{errors.brief}</p>}</div> <button type="submit" className="bg-foreground text-background focus-visible:ring-ring/50 inline-flex h-16 items-center rounded-full px-10 text-lg font-bold outline-none transition-transform hover:-translate-y-0.5 focus-visible:ring-[3px] motion-reduce:transition-none">Send the brief</button> </form> )} <aside aria-label="Offices" className="space-y-10"> {[["Lisbon", "Rua da Boavista 12, 1200-066", "Mon to Fri, 9 to 18"], ["Berlin", "Oranienstraße 164, 10969", "Mon to Fri, 9 to 18"]].map(([c, a, t]) => <div key={c} className="border-t-2 pt-4"><h2 className={cn("text-4xl", studioDisplay)}>{c}</h2><p className="mt-3 text-lg">{a}</p><p className="text-muted-foreground text-sm" style={mono}>{t}</p></div>)} <p className="text-muted-foreground text-sm text-pretty">Looking for a job instead? We hire one or two people a year, usually after seeing their work somewhere else. Send it anyway.</p> </aside> </div> </main> </StudioShell> ) } export { StudioContact, type StudioContactProps }app/studio/page.tsx// Ballmac UI: Studio template route. https://ui.ballmac.com/templates/template-studio import type { Metadata } from "next" import { StudioHome } from "@/components/ballmac/templates/studio/studio-home" export const metadata: Metadata = { title: "Hollis & Vane: brands that move people", description: "An independent branding and digital studio in Lisbon and Berlin.", } export default function Page() { return <StudioHome /> }app/studio/work/page.tsx// Ballmac UI: Studio template route. https://ui.ballmac.com/templates/template-studio import type { Metadata } from "next" import { StudioWork } from "@/components/ballmac/templates/studio/studio-work" export const metadata: Metadata = { title: "Work · Hollis & Vane", description: "Identities, websites and campaigns.", } export default function Page() { return <StudioWork /> }app/studio/work/[slug]/page.tsx// Ballmac UI: Studio template route. https://ui.ballmac.com/templates/template-studio import type { Metadata } from "next" import { StudioProject } from "@/components/ballmac/templates/studio/studio-project" export const metadata: Metadata = { title: "North Coast Rail · Hollis & Vane", description: "A railway identity that moves.", } // One layout for every project. Load the project by params.slug from your data when you add more. export default function Page() { return <StudioProject /> }app/studio/services/page.tsx// Ballmac UI: Studio template route. https://ui.ballmac.com/templates/template-studio import type { Metadata } from "next" import { StudioServices } from "@/components/ballmac/templates/studio/studio-services" export const metadata: Metadata = { title: "Services · Hollis & Vane", description: "Brand identity, digital design, campaigns and a retained studio.", } export default function Page() { return <StudioServices /> }app/studio/contact/page.tsx// Ballmac UI: Studio template route. https://ui.ballmac.com/templates/template-studio import type { Metadata } from "next" import { StudioContact } from "@/components/ballmac/templates/studio/studio-contact" export const metadata: Metadata = { title: "Contact · Hollis & Vane", description: "Send us a brief. We reply within two working days.", } export default function Page() { return <StudioContact /> }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/studio
- Work/studio/work
- Project/studio/work/north-coast
- Services/studio/services
- Contact/studio/contact
- Fonts
- Archivo, Geist Mono (Google Fonts, loaded with next/font)
- Files
- 13 files · 3 Ballmac blocks and components
Built with
Dependencies
Use with AI
Installs a five-page agency site. Change the content in studio-data.ts, the palette in studioCss, and the painted posters in StudioArt (or swap them for real imagery). With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Studio: creative agency (@ballmac/template-studio) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Agencies, studios and collectives that want a loud, typographic site
- Any site where the work should carry the page
Not for
- A personal portfolio with long case studies (use template-portfolio)
Registry JSON: https://ui.ballmac.com/r/template-studio.json
Free to use in personal and commercial projects.