Designing this site with Tailwind CSS and GSAP

The awkward part of this site’s interface is visible in the source before an animation even runs. A component can accumulate a long string of utility classes while staying quick to change, then gain an imperative animation that needs refs, effects, media queries, and cleanup. I get composition speed and precise motion. I also get two different kinds of density in the same React component.

That tension is more useful than a generic tour of three libraries. The repository can show exactly what the current toolkit does; it cannot establish why every dependency was selected in the first place. So this is a present-day evaluation of the code, not a reconstructed origin story or a claim that a historical bake-off happened.

The manifest currently declares Tailwind CSS ^4.3.1, Headless UI ^2.2.10, and GSAP ^3.15.0. Their responsibilities are fairly distinct. Tailwind composes the visual system in markup. Headless UI supplies the mobile navigation’s popover behavior without prescribing its appearance. GSAP owns the homepage’s scrambled text, pointer-driven card tilt, and animated card reordering. The arrangement works because each tool has a bounded job. The maintenance cost appears where those boundaries meet React and the browser.

Tailwind makes the local decision fast

Tailwind’s strongest argument is concrete in Header.jsx. Responsive display, dark mode, focus rings, spacing, color, and state transitions sit beside the elements they affect. I do not have to name a selector, jump to a stylesheet, and reconstruct which rules are safe to change before adjusting a menu panel. The same is true in PrincipleCardStacks.jsx: the grid becomes three columns at lg, card dimensions vary by breakpoint, and the button’s focus treatment is readable at the point of use.

Tailwind CSS 4 also gives the project a small global seam instead of forcing every choice into JSX. The actual stylesheet imports Tailwind and its typography plugin, defines the class-based dark variant, and puts the type scale in @theme:

@import 'tailwindcss';
@import './prism.css' layer(components);

@plugin '@tailwindcss/typography';
@config '../../typography.js';

@custom-variant dark (&:where(.dark, .dark *));

@theme {
  --text-xs: 0.8125rem;
  --text-xs--line-height: 1.5rem;
  --text-sm: 0.875rem;
  --text-sm--line-height: 1.5rem;
  --text-base: 1rem;
  --text-base--line-height: 1.75rem;
  --text-lg: 1.125rem;
  --text-lg--line-height: 1.75rem;
  --text-xl: 1.25rem;
  --text-xl--line-height: 2rem;
  --text-2xl: 1.5rem;
  --text-2xl--line-height: 2rem;
  --text-3xl: 1.875rem;
  --text-3xl--line-height: 2.25rem;
  --text-4xl: 2rem;
  --text-4xl--line-height: 2.5rem;
  --text-5xl: 3rem;
  --text-5xl--line-height: 3.5rem;
  --text-6xl: 3.75rem;
  --text-6xl--line-height: 1;
  --text-7xl: 4.5rem;
  --text-7xl--line-height: 1;
  --text-8xl: 6rem;
  --text-8xl--line-height: 1;
  --text-9xl: 8rem;
  --text-9xl--line-height: 1;
}

The benefit is fast composition with a constrained vocabulary. The cost is that the vocabulary can overwhelm the sentence. The mobile menu button’s classes describe its translucent background, padding, type, shadow, ring, backdrop blur, dark theme, and hover state in one attribute. The principle cards add arbitrary widths, a custom perspective value, several breakpoints, and dark focus styles. That markup is explicit, but it is not automatically easy to scan.

Class-heavy markup moves styling context into the component; it does not make the context disappear. A reviewer still has to separate structure from visual detail, repeated combinations still need an abstraction when they become a real pattern, and an unconstrained arbitrary value can weaken the shared system just as surely as a one-off CSS declaration. Extracting every long string would throw away Tailwind’s locality. Never extracting one would turn JSX into a compressed stylesheet. The useful boundary is repetition and meaning, not character count.

Plain CSS or CSS Modules would be a better fit if semantic class names and a shorter component tree mattered more than colocated variants, especially for a site maintained by designers who prefer the cascade as the primary interface. A larger product with a stable set of repeated controls could also justify a stronger variant-based component layer so feature code rarely sees raw utility strings. Those approaches trade some local composition speed for a more centralized styling contract. That can be the right trade; it is not a defect in either direction.

Headless UI owns behavior, not the visual system

The mobile navigation shows why Headless UI fits between React and Tailwind. Header.jsx builds it from Popover, PopoverButton, PopoverBackdrop, and PopoverPanel. The panel opts into focus behavior and transition state, while Tailwind classes style data-closed, data-enter, and data-leave. Menu links use PopoverButton rendered as Next.js Link components, so selecting a link also participates in the popover interaction instead of requiring separate open-state wiring in every item.

That division is valuable. The component can use an unstyled interaction primitive rather than duplicating popover state and keyboard/focus semantics, and the interface still looks like this site. But “headless” is not “finished.” It does not shorten the visual class lists, choose the navigation information architecture, or decide whether this interaction should be a popover at all. I still have to compose the right primitives and test the result in context.

For a static set of links that never collapses, plain navigation markup is better because there is no open state to manage. For a highly unusual interaction that does not fit the popover model, forcing it through a headless primitive can be harder to understand than a focused custom component—or a native browser element when its semantics match. The benefit comes from using the primitive that describes the behavior, not from putting a component library between every pair of tags.

GSAP earns its place in two specific interactions

The motion code is not a blanket replacement for CSS transitions. The header already uses short CSS-driven opacity and scale transitions for its mobile panel. GSAP appears where the interaction needs more control.

IntroScrambleText registers ScrambleTextPlugin and supports two reveal modes. The direct mode lets the plugin restore each target’s original text; the text-stream mode temporarily replaces text nodes with character spans and tweens a progress object while revealing the real characters. The component locks its measured height during the effect, then removes that lock on completion. That is more expressive than a simple fade, and it is also much more state to own than a simple fade.

PrincipleCardStacks uses a different part of GSAP. gsap.quickTo creates reusable setters for the top card’s rotationX and rotationY and for the content’s x and y, avoiding a fresh tween definition on every pointer move. The Flip plugin captures positions before React reorders a stack, then animates from the old layout to the new one. The actual cycleStack callback keeps the state change available when motion is reduced and brackets the animated path with guards and cleanup:

let cycleStack = useCallback(
  (stackId) => {
    let stackElement = stackRefs.current[stackId]

    if (!stackElement || activeFlips.current[stackId]?.isActive()) {
      return
    }

    if (window.matchMedia(REDUCED_MOTION_QUERY).matches) {
      setStackOrders((currentOrders) => ({
        ...currentOrders,
        [stackId]: rotateTopCardToBack(currentOrders[stackId]),
      }))
      return
    }

    let cards = Array.from(
      stackElement.querySelectorAll('[data-principle-card]'),
    )

    if (cards.length === 0) {
      return
    }

    clearStackTilt(stackId)

    let state = Flip.getState(cards)

    flushSync(() => {
      setStackOrders((currentOrders) => ({
        ...currentOrders,
        [stackId]: rotateTopCardToBack(currentOrders[stackId]),
      }))
    })

    activeFlips.current[stackId] = Flip.from(state, {
      absolute: true,
      duration: 0.6,
      ease: 'sine.inOut',
      nested: true,
      onComplete: () => {
        delete activeFlips.current[stackId]
      },
    })
  },
  [clearStackTilt],
)

This is the expressive side of the trade. React owns the card order, Flip bridges the before-and-after layouts, and the interaction remains a real button with a focus-visible ring and an aria-label that describes the current top card. The result would be cumbersome to reproduce with independent delays or hand-calculated transforms.

Motion sends a lifecycle bill

GSAP does not make React lifecycle work optional. IntroScrambleText runs in a client-side layout effect, scopes its animations with gsap.context, reverts that context during cleanup, kills the custom text-stream tween, restores the original markup, and clears its temporary height. PrincipleCardStacks kills active Flip animations on unmount and kills the tweens behind its quickTo setters whenever the stack order changes. Those cleanup paths are part of the feature, not polish to add after the animation looks right.

The same is true for performance. The cards use will-change-transform, and the pointer response updates transform properties rather than layout properties. quickTo reuses tween machinery for frequently updated values. Those are sensible choices visible in the code, but they are not a benchmark or a guarantee. Measuring bounds, mutating text nodes, and animating multiple layers still spend browser work. Every additional flourish needs to justify that work on the actual devices the site supports.

Accessibility has an equally explicit branch. Both motion components check (prefers-reduced-motion: reduce). The text component returns before it scrambles or mutates the rendered text. The card component skips pointer tilt and Flip animation, but clicking still updates the React state and advances the stack. Reduced motion therefore removes the effect without removing the content or control. GSAP enables the animation; the application code is what makes that policy real.

The alternatives set the stopping rule

CSS transitions and keyframes are the better choice for a bounded opacity, color, or transform change with no sequencing or layout measurement. This site’s menu transition is already an example. The Web Animations API can cover a small imperative sequence without adding an animation library, though the component still has to own cancellation and reduced-motion behavior. And omitting nonessential motion is a strong default for an information-dense site where movement does not clarify state.

GSAP becomes worth its weight here where plugins and runtime control materially simplify the interaction: text scrambling, reusable pointer setters, and FLIP layout transitions. If those features disappeared and the site kept only short entrance fades, I would remove GSAP rather than preserve it for the possibility of future choreography. Likewise, if the interface grew into a multi-application design system, I would put more styling behind named components and tokens instead of asking every consumer to repeat long Tailwind class lists.

The honest boundary is not “utilities good” or “animation library bad.” I use Tailwind when seeing the visual decision beside the markup makes iteration faster, Headless UI when an unstyled interaction primitive matches the behavior, and GSAP when motion needs control that CSS does not express cleanly. Each choice stops paying off when its surrounding ceremony becomes larger than the interface problem it solves. This repository contains both halves of that equation, which is exactly why the toolkit is worth discussing.