The Transient
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 fascinating a part of the mission. How do you make one thing really feel top quality and interesting once you’re intentionally stripping away visible complexity? The reply, for us, was movement. Each interplay, each transition, each hover state turned a possibility so as to add craft with out including muddle.
Design Course and Visible Language
(by Williams Alamu)
At the beginning of the mission, the consumer gave artistic freedom, with a way of what they actually wished. This helped us transfer quick. We explored a number of instructions and shared early ideas. They have been fast and particular with the suggestions they gave over among the ideas.
From these critiques, they wished an internet site that’s minimal however with uniqueness. We selected enjoying into daring headlines, small supporting textual content, and fluid movement. The aim was easy. Take away something pointless. Preserve solely what might function a robust identification for the mission after which amplify it throughout all pages.
Minimalism
The design embraces a minimalist but daring nearly strictly monochromatic, with a structured typographic system (only a few font sizes). The format is deliberately clear, however the scroll animations and small interactions add motion and preserve the expertise participating and fluid.
Typography & Shade system
The model didn’t begin with an outlined identification system. We needed to outline the web site branding from scratch whereas respecting the prevailing brand idea. Because the current brand used a condensed typeface, we searched for the same condensed household and paired it with a nice-looking sans serif.
The location copy was minimal, so the work wanted to talk via construction and hierarchy. We examined many format and sort mixtures to know how every pairing might look throughout completely different pages.
For colour, we stripped away from the potential of having so many colour mixture early. We select a black and white system to maintain consideration on kind, spacing, and movement.

Visuals
All visuals throughout the positioning got here from the consumer’s tasks. The consumer determined in opposition to exterior imagery as they’d all we might potential want when it got here to imagery. These works themself turned the visible language.
Movement as a Core Design Materials
We handled movement as a core design factor, not ornament. Movement strengthened construction, pacing, and model character. We made early exams in Figma and extra complicated sequences have been moved to Jitter.
Displaying movement prototypes helped us clarify concepts sooner than static frames.
Listed below are a number 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 pictures. This provides depth with out distracting from content material.
- Fluid transitions: Transitions really feel nearer to editorial cuts than commonplace fades. Hover states react quick and clearly. Web page adjustments and mission navigation preserve the identical tempo and logic.
Tech Stack
Earlier than diving into specifics, right here’s what we have been working with:
The Basis: Webflow-Dev-Setup
Earlier than we get into particular options, it’s value explaining what’s working beneath. Federico Valla’s Webflow-Dev-Setup has grow to be the spine of each mission we construct at Balky. It solves a standard scaling problem: Webflow is nice for format and CMS, however the second you add complicated customized JavaScript (particularly with web page transitions), it helps to carry extra construction to how code is organized.
The setup offers you a correct module system. Any factor with a data-module attribute mechanically will get found and initialized:
<div data-module="cursor">...</div>
<div data-module="marquee" data-marquee-speed="0.5">...</div>
// src/modules/cursor.ts
export default operate (factor: HTMLElement, dataset: DOMStringMap) {
// Your element code right here
// dataset comprises any data-* attributes from the factor
}
The true 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, removing)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 operate (factor: HTMLElement) {
let animation: gsap.core.Tween;
onMount(() => {
// Setup: add occasion listeners, begin animations
animation = gsap.to(factor, { rotation: 360, repeat: -1 });
});
onPageIn(async () => {
// Animate factor in when web page masses
await gsap.fromTo(factor, { opacity: 0 }, { opacity: 1 });
});
onPageOut(async () => {
// Animate factor out earlier than web page transition
await gsap.to(factor, { opacity: 0 });
});
onDestroy(() => {
// Cleanup: take away listeners, kill animations
animation?.kill();
});
}
This issues due to web page transitions. Once you navigate with Taxi.js, the previous web page’s DOM will get eliminated however any JavaScript connected to these parts retains working until 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 mechanically. When a web page transitions out, onDestroy fires for each module on that web page. When the brand new web page masses, 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 bold tasks like 1820 potential as a result of we are able to have morphing cursors, parallax results, and complicated loaders with out worrying about zombie code haunting us after navigation.
Session-Conscious Loading
We wished the loader expertise to really feel intentional. First-time guests get the total 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 nevertheless it makes the distinction between a website that feels thought of 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 suitable because it shrinks. Small element, nevertheless 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 straightforward, however they every have tradeoffs. We went with Taxi.js for its simplicity, however ran right into a problem: we wished each the outgoing and incoming pages seen concurrently so we might animate one over the opposite.
Taxi has a removeOldContent: false possibility for precisely this nevertheless 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 parts 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 masses beneath.
Particular shout out to Net Engineer Seyi Oluwadare who spent someday with me understanding this logic.
async onLeave({ from, carried out }) {
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",
prime: 0,
left: 0,
width: "100%",
zIndex: 2,
});
// Offset inside content material by scroll place so it does not leap
gsap.set(fromInner, {
place: "absolute",
prime: -scrollPosition,
});
Scroll.cease();
// Retailer cleanup for later — do not run it but
this.pendingCleanup = { from };
carried out();
}
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 essential half is when cleanup occurs: we anticipate each the animation to finish and the brand new web page to completely initialize earlier than eradicating the previous DOM.
async onEnter({ to, carried out }) {
Scroll.begin();
Scroll.toTop();
// Initialize new web page parts
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;
});
},
}, "<");
carried out();
}
The important thing perception is sequencing: the animation runs, then we anticipate the brand new web page’s parts to completely mount, then we destroy the previous web page’s parts 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 wished a cursor that would rework between states, a dot by default, play/pause icons over video, arrows for sliders. GSAP’s MorphSVGPlugin handles the form tweening, however the actual problem was making it work reliably in an SPA the place parts are continually 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: factor.querySelector('[data-cursor-svg="dot"]'),
play: factor.querySelector('[data-cursor-svg="play"]'),
pause: factor.querySelector('[data-cursor-svg="pause"]'),
subsequent: factor.querySelector('[data-cursor-svg="right-arrow"]'),
prev: factor.querySelector('[data-cursor-svg="left-arrow"]'),
};
Object.entries(icons).forEach(([key, icon]) => {
const path = icon?.querySelector("path");
if (path)
});
};
extractPathData();
The Morph Perform
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 — sluggish
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.rework = `translate(${smoothX0}px, ${smoothY0}px)`;
cursorCircle.fashion.rework = `translate(${smoothX1}px, ${smoothY1}px)`;
cursorLabel.fashion.rework = `translate(${smoothX2}px, ${smoothY2}px)`;
};
Occasion Delegation for SPAs
Since we’re utilizing web page transitions, parts get destroyed and recreated continually. As an alternative of attaching listeners to every set off factor and cleansing them up on each navigation, we use document-level occasion delegation.
const setupMorphEvents = () => {
const handleMouseEnter = (e: Occasion) => {
const goal = (e.goal as Component).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 Component).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 permits seize section, making certain we catch occasions earlier than they bubble. This sample means we by no means should 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’d like for a steady scroll.
import Core from "smooothy";
const slider = new Core(marqueeElement, {
infinite: true,
snap: false,
onUpdate: ({ pace }) => {
if (isPointerActive && Math.abs(pace) > 0.0005) {
directionMultiplier = pace > 0 ? 1 : -1;
}
},
});
let directionMultiplier = -1; // RTL by default
const slidesPerSecond = 0.25;
operate 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, nevertheless it makes the interplay really feel responsive fairly 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 mission strengthened one thing we’ve been enthusiastic about quite a bit at Balky: minimal doesn’t imply static. The 1820 website has nearly no visible complexity: muted colours, massive kind, a lot of whitespace nevertheless it feels alive as a result of each interplay has been thought of.
The Webflow-Dev-Setup module system made this potential. When you might have clear element lifecycles and correct cleanup, you may be bold with interactions with out worrying about issues breaking after navigation. Shoutout to Federico Valla for open-sourcing the instruments that made this mission clean.
If we have been to do it once more, we’d in all probability push the web page transitions additional — perhaps some FLIP animations between mission thumbnails and their element pages. However scope is scope, and typically delivery beats good.
Credit


