The prototype walkthrough covered the React Native app and the Go API. Before any of that ships to real users, there’s a website - a public face for the product that sets expectations, explains the idea and gives people somewhere to follow along. Building it turned out to be a good opportunity to make some deliberate choices about animation and interaction, so it’s worth writing up.
Why build the site now
A website before a product launch isn’t unusual, but it’s worth being intentional about what you’re building. The Elaira site has one job: convince you that this is worth following. It’s not a dashboard, it’s not a docs site - it’s a marketing page with a strong point of view. That scope made the technical decisions easy: static by default, interactive only where it earns it, fast.
The Stack
The site is built with:
- Astro 5 - core framework, static output by default
- React - for interactive sections as client islands
- Tailwind CSS - for styling
- motion/react - for all animation
- Cloudflare Workers - for hosting via Wrangler
Astro was the obvious starting point. I used it for this portfolio and it suits this kind of content site well. The island architecture means each interactive component opts in to JavaScript explicitly - everything else is plain HTML.
motion/react (the React integration for Framer Motion) does the animation work throughout. The alternative was CSS transitions where possible, but the kind of staggered, spring-based entrance animations the site uses are awkward in pure CSS and much cleaner with a proper animation library. It also made reduced motion support straightforward - checking prefers-reduced-motion once and skipping to the visible state handles accessibility without branching the component logic.
Cloudflare Workers via Wrangler was a pragmatic choice. It’s fast, the free tier is generous and deploying an Astro site to it takes almost no configuration. For a project at this stage that’s the right call.
The Hero
The hero keeps it direct: Know your score. One sentence of context, a CTA, and a screenshot of the app. The phone image has a parallax scroll effect - as you scroll down it rises and scales up slightly, giving the impression the content is pulling away from the screen.
const imageY = useTransform(scrollY, [0, 800], [0, isMobile ? 200 : 280]);
const imageScale = useTransform(scrollY, [0, 800], [1, 1.125]);
useScroll and useTransform from motion/react map scroll position to CSS transform values. On mobile the travel distance is shorter because the viewport is and the image needs less parallax to feel right. The text scales down slightly in the opposite direction, which reinforces the separation between foreground and background.
One detail worth noting: there’s a brief flash between the initial (static) state and the scrolled state as the scroll event fires for the first time. The fix is an isScrolling flag that starts false and flips to true on the first scroll event, which defers applying the transform until it’s actually needed and prevents that initial jump.
The InReality Carousel
The most technically interesting section is InReality - a full-bleed vertical carousel of prototype screenshots with a dock-style thumbnail navigation on the left.
The thumbnails magnify on hover in the same way macOS dock icons do: the hovered item grows, and the items immediately adjacent to it grow slightly less, with the effect falling off with distance.
const THUMBNAIL_SIZES = {
default: { width: 70, height: 44 },
hover: { width: 86, height: 53 },
distance1: { width: 78, height: 49 },
distance2: { width: 74, height: 46 },
};
The border indicator that tracks the active item uses CSS custom properties to position itself - the position is calculated by summing the heights of all preceding items at their current magnification state, which means the border stays precisely aligned even as thumbnails animate between sizes.
const calculateBorderPosition = (hoverIndex: number | null) => {
let position = 0;
for (let i = 0; i < targetIndex; i++) {
const distanceFromHover = Math.abs(i - hoverIndex);
// pick the right height for this item's current magnification state
position += sizeForDistance(distanceFromHover).height;
position += SPACING.itemPadding * 2;
}
return position;
};
The carousel itself is built on shadcn/ui’s Carousel component (which wraps Embla under the hood) with a vertical orientation. Clicking a thumbnail snaps the carousel to the corresponding slide via api.scrollTo(index).
Animation Philosophy
Every animated section follows the same pattern: observe when the element enters the viewport with useInView, start the animation with useAnimation, respect prefers-reduced-motion by initialising straight to the visible state if it’s set.
The animations are spring-based rather than duration-based wherever possible:
transition: {
type: 'spring',
stiffness: 80,
damping: 20,
}
Spring physics feel more natural than eased duration curves for entrance animations because they have a natural deceleration as they settle - the same reason iOS uses springs throughout UIKit. The stagger between child elements (typically 150–200ms) is enough to give hierarchy without feeling slow.
Dark Mode
The site supports dark mode via a theme toggle in the navbar, with the preference persisted to localStorage. The implementation uses a straightforward React context - a ThemeToggle component reads from and writes to local storage, and the dark class on the document root switches Tailwind’s dark variant classes.
Wrapping Up
The site is live at elaira-app.com. The “Follow the build” CTA in the nav links back to this blog - the intention is to keep building in public and post updates here as the Go API, TrueLayer integration and eventual app launch progress.
If you want to dig into the code, the prototype walkthrough is still the best place to start on the product side. The website source is on GitHub.