The Temporary
1820 Productions isn’t your common video manufacturing firm. They’ve labored with Toyota, 7-Eleven, Megan Thee Stallion, and networks like ABC, CBS, and BET. After they got here to us at Balky Studio, the temporary was deceptively easy: a brand new web site that sells the standard of their work.
The one stipulation? Preserve it minimal. Preserve it easy.
That constraint turned out to be probably the most attention-grabbing a part of the challenge. How do you make one thing really feel top quality and interesting if you’re intentionally stripping away visible complexity? The reply, for us, was movement. Each interplay, each transition, each hover state grew to become a possibility so as to add craft with out including muddle.
Design Course and Visible Language
(by Williams Alamu)
Initially of the challenge, the shopper gave inventive freedom, with a way of what they actually needed. This helped us transfer quick. We explored a number of instructions and shared early ideas. They had been fast and particular with the suggestions they gave over a few of the ideas.
From these critiques, they needed a web site that’s minimal however with uniqueness. We selected enjoying into daring headlines, small supporting textual content, and fluid movement. The objective was easy. Take away something pointless. Preserve solely what may function a robust id for the challenge after which amplify it throughout all pages.
Minimalism
The design embraces a minimalist but daring virtually strictly monochromatic, with a structured typographic system (only a few font sizes). The structure is deliberately clear, however the scroll animations and small interactions add motion and preserve the expertise partaking and fluid.
Typography & Coloration system
The model didn’t begin with an outlined id system. We needed to outline the web site branding from scratch whereas respecting the present emblem idea. Because the present emblem used a condensed typeface, we searched for the same condensed household and paired it with a nice-looking sans serif.
The positioning copy was minimal, so the work wanted to talk via construction and hierarchy. We examined many structure and sort combos to grasp how every pairing may look throughout completely different pages.
For coloration, we stripped away from the potential of having so many coloration mixture early. We select a black and white system to maintain consideration on type, spacing, and movement.

Visuals
All visuals throughout the positioning got here from the shopper’s initiatives. The shopper determined in opposition to exterior imagery as that they had all we may attainable want when it got here to imagery. These works themself grew to become the visible language.
Movement as a Core Design Materials
We handled movement as a core design aspect, not ornament. Movement strengthened construction, pacing, and model character. We made early exams in Figma and extra complicated sequences had been moved to Jitter.
Displaying movement prototypes helped us clarify concepts quicker than static frames.
Listed here are a couple of of the movement highlights:
- Line animations: We used traces as part markers. They introduce new sections and set rhythm for the web page.
- Picture parallax: Vertical and horizontal scroll each set off parallax on featured photographs. This provides depth with out distracting from content material.
- Fluid transitions: Transitions really feel nearer to editorial cuts than normal fades. Hover states react quick and clearly. Web page adjustments and challenge navigation preserve the identical tempo and logic.
Tech Stack
Earlier than diving into specifics, right here’s what we had been working with:
The Basis: Webflow-Dev-Setup
Earlier than we get into particular options, it’s price explaining what’s operating beneath. Federico Valla’s Webflow-Dev-Setup has grow to be the spine of each challenge we construct at Balky. It solves a standard scaling problem: Webflow is nice for structure and CMS, however the second you add complicated customized JavaScript (particularly with web page transitions), it helps to convey extra construction to how code is organized.
The setup offers you a correct module system. Any aspect with a data-module attribute robotically will get found and initialized:
...
...
// src/modules/cursor.ts
export default perform (aspect: HTMLElement, dataset: DOMStringMap) {
// Your element code right here
// dataset accommodates any data-* attributes from the aspect
}
The actual energy is within the lifecycle hooks. Every module will get entry to:
onMount— runs when the element initializesonDestroy— runs when the element is torn down (web page transition, elimination)onPageIn— runs when a brand new web page animates inonPageOut— runs when the present web page animates out
import { onMount, onDestroy, onPageIn, onPageOut } from "@/modules/_";
export default perform (aspect: HTMLElement) {
let animation: gsap.core.Tween;
onMount(() => {
// Setup: add occasion listeners, begin animations
animation = gsap.to(aspect, { rotation: 360, repeat: -1 });
});
onPageIn(async () => {
// Animate aspect in when web page hundreds
await gsap.fromTo(aspect, { opacity: 0 }, { opacity: 1 });
});
onPageOut(async () => {
// Animate aspect out earlier than web page transition
await gsap.to(aspect, { opacity: 0 });
});
onDestroy(() => {
// Cleanup: take away listeners, kill animations
animation?.kill();
});
}
This issues due to web page transitions. While you navigate with Taxi.js, the previous web page’s DOM will get eliminated however any JavaScript connected to these parts retains operating except you explicitly clear it up. Occasion listeners pile up. GSAP tweens goal parts that now not exist. Reminiscence leaks accumulate.
The module system handles this robotically. When a web page transitions out, onDestroy fires for each module on that web page. When the brand new web page hundreds, modules are found and onMount fires. You write your element as soon as, and the lifecycle is managed for you.
It additionally integrates Lenis for clean scroll, has a subscription system for RAF and resize occasions, and handles Webflow editor detection so your animations play properly within the Designer. However the lifecycle administration is what makes formidable initiatives like 1820 attainable as a result of we will have morphing cursors, parallax results, and sophisticated loaders with out worrying about zombie code haunting us after navigation.
Session-Conscious Loading
We needed the loader expertise to really feel intentional. First-time guests get the complete branded intro; returning guests get one thing snappier. The trick is easy, a light-weight session controller that checks sessionStorage on init.
class _SessionController {
personal isFirstSession: boolean;
constructor() {
const firstSession = sessionStorage.getItem("firstSession");
if (!firstSession) {
this.isFirstSession = true;
sessionStorage.setItem("firstSession", "true");
} else {
this.isFirstSession = false;
}
}
get firstSession(): boolean {
return this.isFirstSession;
}
}
export const SessionController = new _SessionController();
Then within the loader, we department:
if (SessionController.firstSession) {
runFullIntro();
} else {
runQuickLoader();
}
Nothing fancy however it makes the distinction between a web site that feels thought-about vs. one which performs the identical animation each time.
The Loader Animation
The loader runs two GSAP timelines in parallel, one for animating content material in (icons spin up, model slides in), one for animating it out and revealing the web page beneath.
// Content material IN — icons and model animate in
const contentTl = gsap.timeline({ paused: true });
contentTl.fromTo(loaderIcons,
{ scale: 0, rotate: 270, opacity: 0 },
{ scale: 1, rotate: 0, opacity: 1, ease: "expo.out", length: 1.5, stagger: 0.08 }
);
contentTl.to(loaderBrand,
{ y: "0%", opacity: 1, ease: "expo.out", length: 1.4 },
"<"
);
// Content material OUT — progress bar + clip-path reveal
const outTl = gsap.timeline({ paused: true });
outTl.to(loaderProgress, {
scaleX: 1,
length: 1.5,
ease: "expo.inOut",
onComplete: () => {
gsap.to(loaderProgress, { x: "100%", scaleX: 0.5, length: 1, ease: "expo.inOut" });
},
});
outTl.to(loaderMain, {
clipPath: "inset(0% 0% 100% 0%)",
length: 1,
ease: "expo.inOut",
}, "<");
The progress bar doesn’t simply fill and disappear, it overshoots barely, sliding off to the appropriate because it shrinks. Small element, however it makes the entire thing really feel extra fluid.
Web page Transitions: The Cleanup Drawback
Web page transition libraries like Barba.js and Taxi.js make SPA-style navigation simple, however they every have tradeoffs. We went with Taxi.js for its simplicity, however ran right into a problem: we needed each the outgoing and incoming pages seen concurrently so we may animate one over the opposite.
Taxi has a removeOldContent: false possibility for precisely this however it does imply you want a transparent cleanup technique. Depart the previous web page within the DOM and also you’ve bought reminiscence leaks. Destroy elements too early and also you get flashes of unstyled content material.
const PAGES_CONFIG = {
removeOldContent: false,
allowInterruption: false,
};
Freezing the Previous Web page
The trick is freezing the previous web page precisely the place it’s. When the person clicks a hyperlink, we seize the present scroll place, change the outgoing web page to place: absolute, and offset it by the scroll quantity. This retains it visually locked in place whereas the brand new web page hundreds beneath.
Particular shout out to Net Engineer Seyi Oluwadare who spent someday with me figuring out this logic.
async onLeave({ from, executed }) {
const fromInner = from.querySelector(".page_view_inner");
const scrollPosition = Scroll.currentScroll;
// Retailer reference for cleanup later
this.oldContainer = from;
// Freeze the previous web page in place
gsap.set(from, {
place: "absolute",
high: 0,
left: 0,
width: "100%",
zIndex: 2,
});
// Offset inside content material by scroll place so it would not leap
gsap.set(fromInner, {
place: "absolute",
high: -scrollPosition,
});
Scroll.cease();
// Retailer cleanup for later — do not run it but
this.pendingCleanup = { from };
executed();
}
The Reveal + Deferred Cleanup
In onEnter, we run the precise transition animation with a delicate upward drift with an overlay fade, then the clip-path reveal. The vital half is when cleanup occurs: we anticipate each the animation to finish and the brand new web page to totally initialize earlier than eradicating the previous DOM.
async onEnter({ to, executed }) {
Scroll.begin();
Scroll.toTop();
// Initialize new web page elements
const transitionInPromise = App.pages.transitionIn({ to });
// Animate the previous web page out
const transTl = gsap.timeline();
transTl.to(oldPageOverlay, { opacity: 1, length: 1.1, ease: "expo.inOut" });
transTl.to(oldPageInner, { y: -125, length: 1.1, ease: "expo.inOut" }, "<");
transTl.to(this.oldContainer, {
clipPath: "inset(0% 0% 100%)",
length: 1,
ease: "expo.inOut",
onComplete: () => {
// Wait for brand new web page to be prepared, THEN clear up
transitionInPromise.then(() => {
App.pages.transitionOut(this.pendingCleanup);
this.oldContainer.parentNode.removeChild(this.oldContainer);
this.oldContainer = null;
});
},
}, "<");
executed();
}
The important thing perception is sequencing: the animation runs, then we anticipate the brand new web page’s elements to totally mount, then we destroy the previous web page’s elements and take away it from the DOM. The person by no means sees a damaged intermediate state.
Customized Cursor: SVG Morphing That Survives Navigation
We needed a cursor that would remodel between states, a dot by default, play/pause icons over video, arrows for sliders. GSAP’s MorphSVGPlugin handles the form tweening, however the true problem was making it work reliably in an SPA the place parts are consistently being created and destroyed.
Caching Path Knowledge
Customized cursors with SVG morphing can get costly should you’re querying the DOM on each hover. We cache all the trail information upfront so morphing is only a matter of swapping strings.
gsap.registerPlugin(MorphSVGPlugin);
const pathCache: { [key: string]: string } = {};
const extractPathData = () => {
const icons = {
default: aspect.querySelector('[data-cursor-svg="dot"]'),
play: aspect.querySelector('[data-cursor-svg="play"]'),
pause: aspect.querySelector('[data-cursor-svg="pause"]'),
subsequent: aspect.querySelector('[data-cursor-svg="right-arrow"]'),
prev: aspect.querySelector('[data-cursor-svg="left-arrow"]'),
};
Object.entries(icons).forEach(([key, icon]) => {
const path = icon?.querySelector("path");
if (path) "";
});
};
extractPathData();
The Morph Operate
With paths cached, the precise morph is easy, we kill any in-progress animation, then tween to the brand new form.
let currentMorphAnimation: gsap.core.Tween | null = null;
let currentShape = "default";
const morphToShape = (targetShape: string) => {
if (currentShape === targetShape) return;
if (currentMorphAnimation) {
currentMorphAnimation.kill();
}
currentShape = targetShape;
currentMorphAnimation = gsap.to(cursorPath, {
morphSVG: {
form: pathCache[targetShape],
kind: "rotational",
},
length: 0.5,
ease: "expo.out",
});
};
Layered Motion
The cursor is definitely three parts transferring at completely different speeds, the icon follows quick, the circle follows slower, and a label trails behind. This layering creates a way of weight.
const ease0 = 0.15; // icon — quick
const ease1 = 0.08; // circle — medium
const ease2 = 0.05; // label — gradual
let smoothX0 = 0, smoothY0 = 0;
let smoothX1 = 0, smoothY1 = 0;
let smoothX2 = 0, smoothY2 = 0;
const handleRaf = () => {
smoothX0 += (Mouse.x - smoothX0) * ease0;
smoothY0 += (Mouse.y - smoothY0) * ease0;
smoothX1 += (Mouse.x - smoothX1) * ease1;
smoothY1 += (Mouse.y - smoothY1) * ease1;
smoothX2 += (Mouse.x - smoothX2) * ease2;
smoothY2 += (Mouse.y - smoothY2) * ease2;
cursorIcon.fashion.remodel = `translate(${smoothX0}px, ${smoothY0}px)`;
cursorCircle.fashion.remodel = `translate(${smoothX1}px, ${smoothY1}px)`;
cursorLabel.fashion.remodel = `translate(${smoothX2}px, ${smoothY2}px)`;
};
Occasion Delegation for SPAs
Since we’re utilizing web page transitions, parts get destroyed and recreated consistently. As an alternative of attaching listeners to every set off aspect and cleansing them up on each navigation, we use document-level occasion delegation.
const setupMorphEvents = () => {
const handleMouseEnter = (e: Occasion) => {
const goal = (e.goal as Aspect).closest(
"[data-play-cursor], [data-pause-cursor], [data-next-cursor], [data-prev-cursor]"
);
if (!goal) return;
if (goal.hasAttribute("data-play-cursor")) morphToShape("play");
else if (goal.hasAttribute("data-pause-cursor")) morphToShape("pause");
else if (goal.hasAttribute("data-next-cursor")) morphToShape("subsequent");
else if (goal.hasAttribute("data-prev-cursor")) morphToShape("prev");
};
const handleMouseLeave = (e: Occasion) => {
const goal = (e.goal as Aspect).closest(
"[data-play-cursor], [data-pause-cursor], [data-next-cursor], [data-prev-cursor]"
);
if (goal) morphToShape("default");
};
doc.addEventListener("mouseenter", handleMouseEnter, true);
doc.addEventListener("mouseleave", handleMouseLeave, true);
};
The true parameter allows seize part, guaranteeing we catch occasions earlier than they bubble. This sample means we by no means need to re-attach listeners after navigation.
Marquee with Smooothy
For the brand marquee, we reached for Smooothy, one other library by Federico Valla. It’s designed for sliders, however the infinite mode mixed with handbook goal development offers us precisely what we want for a steady scroll.
import Core from "smooothy";
const slider = new Core(marqueeElement, {
infinite: true,
snap: false,
onUpdate: ({ velocity }) => {
if (isPointerActive && Math.abs(velocity) > 0.0005) {
directionMultiplier = velocity > 0 ? 1 : -1;
}
},
});
let directionMultiplier = -1; // RTL by default
const slidesPerSecond = 0.25;
perform animate() {
const dt = Math.min(slider.deltaTime, 1 / 60);
slider.goal += directionMultiplier * slidesPerSecond * dt;
slider.replace();
requestAnimationFrame(animate);
}
animate();
The onUpdate callback fires throughout drag if the person swipes left, the marquee continues left after they launch. Small element, however it makes the interplay really feel responsive relatively than combating in opposition to you.
Dealing with Browser Tab Visibility
One gotcha with requestAnimationFrame loops: if the person switches browser tabs, the animation pauses however deltaTime accumulates. After they return, the marquee tries to “catch up” and jumps. We reset timing when the tab turns into seen once more.
doc.addEventListener("visibilitychange", () => {
if (doc.visibilityState === "seen" && wasHidden) {
skipNextFrame = true;
slider.present = slider.present;
slider.goal = slider.present;
}
wasHidden = doc.visibilityState === "hidden";
});
Reflections
This challenge strengthened one thing we’ve been interested by lots at Balky: minimal doesn’t imply static. The 1820 web site has virtually no visible complexity: muted colours, huge kind, plenty of whitespace however it feels alive as a result of each interplay has been thought-about.
The Webflow-Dev-Setup module system made this attainable. When you’ve gotten clear element lifecycles and correct cleanup, you may be formidable with interactions with out worrying about issues breaking after navigation. Shoutout to Federico Valla for open-sourcing the instruments that made this challenge clean.
If we had been to do it once more, we’d most likely push the web page transitions additional — possibly some FLIP animations between challenge thumbnails and their element pages. However scope is scope, and generally transport beats excellent.
Credit









