North Studio
Editorial agency site with masked headline reveals, parallax work grid and hover services list.
app/page.tsx
"use client";
import { motion, useScroll, useTransform } from "motion/react";
import { ArrowUpRight, Asterisk } from "lucide-react";
import { useRef, useState } from "react";
const ease = [0.76, 0, 0.24, 1] as const;
function SplitHeading({ lines, className = "" }: { lines: string[]; className?: string }) {
return (
<h1 className={className}>
{lines.map((line, i) => (
<span key={line} className="-mb-[0.12em] block overflow-hidden pb-[0.12em]">
<motion.span
className="block"
initial={{ y: "105%" }}
animate={{ y: "0%" }}
transition={{ duration: 1.1, delay: 0.15 + i * 0.12, ease }}
>
{line}
</motion.span>
</span>
))}
</h1>
);
}
function Hero() {
return (
<section className="px-6 pb-20 pt-16 sm:px-10">
<div className="flex items-center justify-between text-sm text-zinc-500">
<span>Independent design studio</span>
<span className="hidden sm:block">Est. 2014 — Oslo / Remote</span>
</div>
<SplitHeading
lines={["We design brands", "and digital products", "people remember."]}
className="mt-16 text-[clamp(2.75rem,8.5vw,8rem)] font-medium leading-[0.95] tracking-[-0.04em] text-zinc-950"
/>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.8, duration: 0.8 }}
className="mt-16 grid gap-10 border-t border-zinc-200 pt-8 md:grid-cols-[1fr_auto] md:items-end"
>
<p className="max-w-md text-lg text-zinc-600">
A senior team of strategists, designers and engineers helping ambitious companies find their voice and ship work that lasts.
</p>
<a href="#contact" className="group inline-flex items-center gap-3 text-lg font-medium text-zinc-950">
<span className="relative">
Start a project
<span className="absolute -bottom-1 left-0 h-px w-full origin-right scale-x-100 bg-zinc-950 transition-transform duration-500 group-hover:origin-left group-hover:scale-x-0" />
</span>
<span className="grid size-12 place-items-center rounded-full bg-orange-500 text-white transition-transform duration-500 group-hover:rotate-45">
<ArrowUpRight className="size-5" />
</span>
</a>
</motion.div>
</section>
);
}
const projects = [
{ name: "Fjord Bank", type: "Brand identity · Web", year: "2026", color: "from-sky-200 via-sky-100 to-white", accent: "bg-sky-600" },
{ name: "Halden Coffee", type: "Packaging · E-commerce", year: "2025", color: "from-amber-200 via-orange-100 to-white", accent: "bg-orange-600" },
{ name: "Mira Health", type: "Product design · App", year: "2025", color: "from-emerald-200 via-emerald-50 to-white", accent: "bg-emerald-600" },
{ name: "Polar Works", type: "Brand strategy · Motion", year: "2024", color: "from-zinc-300 via-zinc-100 to-white", accent: "bg-zinc-900" },
];
function ProjectCard({ p, i }: { p: (typeof projects)[number]; i: number }) {
const ref = useRef<HTMLAnchorElement>(null);
const { scrollYProgress } = useScroll({ target: ref, offset: ["start end", "end start"] });
const y = useTransform(scrollYProgress, [0, 1], [40, -40]);
return (
<motion.a
href="#"
ref={ref}
initial={{ opacity: 0, y: 40 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.2 }}
transition={{ duration: 0.9, delay: (i % 2) * 0.15, ease: [0.22, 1, 0.36, 1] }}
className={`group block ${i % 2 === 1 ? "md:mt-32" : ""}`}
>
<div className={`relative aspect-[4/5] overflow-hidden rounded-2xl bg-gradient-to-br ${p.color}`}>
<motion.div style={{ y }} className="absolute inset-0 grid place-items-center">
<div className="relative transition-transform duration-700 ease-[cubic-bezier(0.22,1,0.36,1)] group-hover:scale-110">
<div className={`size-40 rounded-full ${p.accent} opacity-90`} />
<div className="absolute -right-10 -top-6 size-20 rounded-2xl bg-white/80 shadow-xl backdrop-blur" />
</div>
</motion.div>
<span className="absolute bottom-5 right-5 grid size-12 translate-y-4 place-items-center rounded-full bg-white text-zinc-950 opacity-0 transition-all duration-500 group-hover:translate-y-0 group-hover:opacity-100">
<ArrowUpRight className="size-5" />
</span>
</div>
<div className="mt-5 flex items-baseline justify-between">
<h3 className="text-2xl font-medium tracking-tight text-zinc-950">{p.name}</h3>
<span className="text-sm text-zinc-400">{p.year}</span>
</div>
<p className="mt-1 text-sm text-zinc-500">{p.type}</p>
</motion.a>
);
}
function Work() {
return (
<section id="work" className="px-6 py-24 sm:px-10">
<div className="flex items-end justify-between">
<h2 className="text-5xl font-medium tracking-tight text-zinc-950 sm:text-7xl">Selected work</h2>
<span className="hidden text-sm text-zinc-500 sm:block">(04)</span>
</div>
<div className="mt-16 grid gap-x-10 gap-y-16 md:grid-cols-2">
{projects.map((p, i) => (
<ProjectCard key={p.name} p={p} i={i} />
))}
</div>
</section>
);
}
const services = [
{ name: "Brand strategy", items: "Positioning, naming, messaging, research" },
{ name: "Visual identity", items: "Logo, typography, color, guidelines" },
{ name: "Digital product", items: "UX research, interface design, prototyping" },
{ name: "Web & engineering", items: "Marketing sites, design systems, Next.js" },
{ name: "Motion", items: "Brand animation, product films, micro-interactions" },
];
function Services() {
const [hovered, setHovered] = useState<number | null>(null);
return (
<section className="bg-zinc-950 px-6 py-24 text-white sm:px-10">
<div className="grid gap-12 lg:grid-cols-[1fr_2fr]">
<div>
<p className="flex items-center gap-2 text-sm text-zinc-400"><Asterisk className="size-4 text-orange-500" /> What we do</p>
<p className="mt-6 max-w-sm text-zinc-400">End-to-end, or exactly the piece you need. Every engagement is led by a senior partner.</p>
</div>
<ul onMouseLeave={() => setHovered(null)}>
{services.map((s, i) => (
<li key={s.name} onMouseEnter={() => setHovered(i)} className="relative border-t border-white/10 last:border-b">
<motion.span
aria-hidden
className="absolute inset-0 origin-bottom bg-orange-500"
initial={false}
animate={{ scaleY: hovered === i ? 1 : 0 }}
transition={{ duration: 0.45, ease }}
/>
<div className="relative flex flex-wrap items-center justify-between gap-2 px-2 py-7">
<span className="text-3xl font-medium tracking-tight sm:text-4xl">{s.name}</span>
<span className={`text-sm transition-colors ${hovered === i ? "text-white" : "text-zinc-500"}`}>{s.items}</span>
</div>
</li>
))}
</ul>
</div>
</section>
);
}
function Marquee() {
const words = ["Strategy", "Identity", "Product", "Motion", "Engineering"];
return (
<section className="overflow-hidden border-y border-zinc-200 py-10">
<motion.div className="flex w-max whitespace-nowrap" animate={{ x: ["0%", "-50%"] }} transition={{ duration: 22, repeat: Infinity, ease: "linear" }}>
{[...words, ...words, ...words, ...words].map((w, i) => (
<span key={i} className="flex items-center gap-10 pr-10 text-6xl font-medium tracking-tight text-zinc-950 sm:text-8xl">
{w}
<Asterisk className="size-12 text-orange-500" />
</span>
))}
</motion.div>
</section>
);
}
function Stats() {
const stats = [
{ n: "120+", l: "Brands launched" },
{ n: "14", l: "Design awards" },
{ n: "11y", l: "In business" },
{ n: "96%", l: "Clients return" },
];
return (
<section className="grid grid-cols-2 gap-px bg-zinc-200 md:grid-cols-4">
{stats.map((s, i) => (
<motion.div key={s.l} initial={{ opacity: 0 }} whileInView={{ opacity: 1 }} viewport={{ once: true }} transition={{ delay: i * 0.1 }} className="bg-white px-6 py-16 sm:px-10">
<p className="text-5xl font-medium tracking-tight text-zinc-950 sm:text-6xl">{s.n}</p>
<p className="mt-2 text-sm text-zinc-500">{s.l}</p>
</motion.div>
))}
</section>
);
}
function Contact() {
return (
<section id="contact" className="px-6 py-32 text-center sm:px-10">
<p className="text-sm text-zinc-500">Have a project in mind?</p>
<motion.a
href="#"
initial={{ opacity: 0, y: 40 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 1, ease: [0.22, 1, 0.36, 1] }}
className="group mt-6 inline-flex items-center gap-4 text-[clamp(2.5rem,9vw,9rem)] font-medium leading-none tracking-[-0.04em] text-zinc-950"
>
Let's talk
<span className="grid size-[0.8em] place-items-center rounded-full bg-orange-500 text-white transition-transform duration-500 group-hover:rotate-45">
<ArrowUpRight className="size-1/2" />
</span>
</motion.a>
<p className="mt-10 text-zinc-600">[email protected]</p>
</section>
);
}
export default function NorthStudioTemplate() {
return (
<div className="min-h-screen bg-white text-zinc-950 antialiased">
<header className="sticky top-0 z-50 mix-blend-normal">
<div className="flex items-center justify-between bg-white/80 px-6 py-5 backdrop-blur-md sm:px-10">
<span className="text-lg font-semibold tracking-tight">North<span className="text-orange-500">.</span></span>
<nav className="flex gap-6 text-sm">
<a href="#work" className="hover:text-orange-500">Work</a>
<a href="#" className="hidden hover:text-orange-500 sm:block">Studio</a>
<a href="#contact" className="hover:text-orange-500">Contact</a>
</nav>
</div>
</header>
<main>
<Hero />
<Marquee />
<Work />
<Services />
<Stats />
<Contact />
</main>
<footer className="flex flex-wrap justify-between gap-4 border-t border-zinc-200 px-6 py-8 text-sm text-zinc-500 sm:px-10">
<span>© {new Date().getFullYear()} North Studio</span>
<div className="flex gap-6">
<a href="#" className="hover:text-zinc-950">Instagram</a>
<a href="#" className="hover:text-zinc-950">LinkedIn</a>
<a href="#" className="hover:text-zinc-950">Dribbble</a>
</div>
</footer>
</div>
);
}
What's included
- Header
- Hero
- Marquee
- Selected work
- Services
- Stats
- Contact
Use it in 3 steps
1. Install dependencies in your Next.js + Tailwind project:
$ npm i motion lucide-react- 2. Copy the code into
app/page.tsx. - 3. Replace the copy, colors and links with your own — then deploy.