Skip to content
jswnthui

Layout

Expertise Grid

A bento of disciplines, each card carrying a small looping visual for its subject — an easing curve, reflowing blocks, a load bar, ranking bars, EN crossfading into Arabic.

Core Expertise

What a small studio of website designers in Kuwait actually spends its time on — the disciplines behind every project, not just the deliverables.

Motion Engineering

GSAP and Lenis, tuned by hand — every easing curve, every scroll trigger, profiled until it holds 60fps on a mid-range phone, not just a demo machine.

Brand-First Design

Every layout starts from your brand and your content — never a page-builder section library.

Sub-second load
Performance Discipline

Next.js, edge rendering, and image optimization built in from the first commit, not bolted on before launch.

SEO Built In

Keyword strategy, structured data, and content architecture — part of the build, not an afterthought.

ENع
Kuwait Market Fluency

Bilingual audiences and local buying behavior, understood firsthand — not guessed at from abroad.

Scroll the page — the cards scale in, each with its own looping visual

Installation

  1. 1. Install the dependencies.

    terminal
    npm install 
  2. 2. Add the cn helper, if you don't already have it.

    lib/utils.ts
    import { clsx, type ClassValue } from "clsx";
    import { twMerge } from "tailwind-merge";
    
    export function cn(...inputs: ClassValue[]) {
      return twMerge(clsx(inputs));
    }
  3. 3. Copy the component into your project.

    components/ui/expertise-grid.tsx
    'use client'
    
    import { useEffect, useRef } from 'react'
    import { onScroll } from '@/lib/on-scroll'
    import {
      BrandVisual,
      CultureVisual,
      MotionVisual,
      PerformanceVisual,
      SeoVisual,
    } from '@/components/ui/kinematics/expertise-visuals'
    
    export type ExpertiseVisual = 'motion' | 'brand' | 'performance' | 'seo' | 'culture'
    
    export interface ExpertiseItem {
      title: string
      description: string
      span: string
      featured: boolean
      visual: ExpertiseVisual
    }
    
    const EXPERTISE_VISUALS: Record<ExpertiseVisual, () => React.JSX.Element> = {
      motion: MotionVisual,
      brand: BrandVisual,
      performance: PerformanceVisual,
      seo: SeoVisual,
      culture: CultureVisual,
    }
    
    const EXPERTISE: ExpertiseItem[] = [
      {
        title: 'Motion Engineering',
        description:
          'GSAP and Lenis, tuned by hand — every easing curve, every scroll trigger, profiled until it holds 60fps on a mid-range phone, not just a demo machine.',
        span: 'sm:col-span-2 lg:col-span-2 lg:row-span-2',
        featured: true,
        visual: 'motion',
      },
      {
        title: 'Brand-First Design',
        description: 'Every layout starts from your brand and your content — never a page-builder section library.',
        span: '',
        featured: false,
        visual: 'brand',
      },
      {
        title: 'Performance Discipline',
        description: 'Next.js, edge rendering, and image optimization built in from the first commit, not bolted on before launch.',
        span: '',
        featured: false,
        visual: 'performance',
      },
      {
        title: 'SEO Built In',
        description: 'Keyword strategy, structured data, and content architecture — part of the build, not an afterthought.',
        span: '',
        featured: false,
        visual: 'seo',
      },
      {
        title: 'Kuwait Market Fluency',
        description: 'Bilingual audiences and local buying behavior, understood firsthand — not guessed at from abroad.',
        span: '',
        featured: false,
        visual: 'culture',
      },
    ]
    
    export function ExpertiseCard({
      item,
      cardRef,
    }: {
      item: ExpertiseItem
      cardRef: (el: HTMLDivElement | null) => void
    }) {
      const Visual = EXPERTISE_VISUALS[item.visual]
    
      return (
        <div
          ref={cardRef}
          className={`group flex flex-col justify-between gap-5 rounded-2xl border-2 border-dashed p-6 transition-colors duration-300 ease-out hover:-translate-y-1 hover:border-kine-primary hover:bg-kine-primary/5 hover:shadow-[0_0_30px_-6px_rgba(206,255,0,0.4)] md:p-8 ${
            item.span
          } ${item.featured ? 'border-kine-primary/60 bg-kine-primary/[0.03]' : 'border-kine-primary/30 bg-transparent'}`}
          style={{ opacity: 0, transform: 'scale(0.6)' }}
        >
          <Visual />
          <div className="flex flex-col gap-3">
            <span className="font-kine-display text-lg uppercase tracking-tight text-kine-primary md:text-2xl">
              {item.title}
            </span>
            <p className="font-kine-body text-sm text-kine-accent/70 md:text-base">{item.description}</p>
          </div>
        </div>
      )
    }
    
    interface ExpertiseGridProps {
      heading?: string
      intro?: string
      items?: ExpertiseItem[]
    }
    
    /** The core-expertise bento: one featured card and four smaller ones, each
     * with a small looping visual for its subject, scaling up and fading in as
     * it enters the viewport. */
    export default function ExpertiseGrid({
      heading = 'Core Expertise',
      intro = 'What a small studio of website designers in Kuwait actually spends its time on — the disciplines behind every project, not just the deliverables.',
      items = EXPERTISE,
    }: ExpertiseGridProps) {
      const cardRefs = useRef<(HTMLDivElement | null)[]>([])
    
      // Continuous scroll-driven reveal (not a one-shot IntersectionObserver
      // snapshot) — recomputes each card's arrival on every scroll tick so it
      // fades/scales in exactly as it enters the viewport, rather than only
      // updating once on the enter/exit edge (which read as "appearing late").
      useEffect(() => {
        const update = () => {
          const viewportH = window.innerHeight
          cardRefs.current.forEach((card) => {
            if (!card) return
            const rect = card.getBoundingClientRect()
            const cardCenter = rect.top + rect.height / 2
            const arrival = Math.min(1, Math.max(0, (viewportH - cardCenter) / (viewportH * 0.5)))
            card.style.opacity = String(arrival)
            card.style.transform = `scale(${0.6 + 0.4 * arrival})`
          })
        }
    
        update()
        return onScroll(update)
      }, [items])
    
      return (
        <section className="border-t border-white/10 bg-kine-bg px-6 py-24 md:py-32">
          <div className="mx-auto flex max-w-6xl flex-col gap-12">
            <div className="flex flex-col gap-6">
              <h2 className="font-kine-display text-3xl uppercase tracking-tight text-kine-primary md:text-5xl">
                {heading}
              </h2>
              <p className="max-w-2xl font-kine-body text-sm text-kine-accent/70 md:text-base">{intro}</p>
            </div>
    
            <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4 lg:auto-rows-fr">
              {items.map((item, i) => (
                <ExpertiseCard
                  key={item.title}
                  item={item}
                  cardRef={(el) => {
                    cardRefs.current[i] = el
                  }}
                />
              ))}
            </div>
          </div>
        </section>
      )
    }
    components/ui/kinematics/expertise-visuals.tsx
    /* The small looping visuals on each Expertise Grid card, one per subject.
       Their keyframes live in styles/kinematics.css. */
    
    // A flowing dashed curve — an "easing curve in motion", standing in for
    // hand-tuned GSAP/Lenis animation. Runs continuously (CSS keyframe, not a
    // one-shot draw-in) since this card is about motion never stopping.
    export function MotionVisual() {
      return (
        <div className="flex min-h-[16rem] w-full flex-1 items-center justify-center rounded-xl border border-white/10 bg-black/20 md:min-h-[22rem]">
          <svg viewBox="0 0 240 100" className="h-32 w-full max-w-md px-8 md:h-44" fill="none">
            <path
              d="M6 70 C 50 70, 50 20, 90 20 S 130 80, 170 80 S 210 24, 234 24"
              stroke="#ceff00"
              strokeOpacity={0.25}
              strokeWidth={3}
              strokeLinecap="round"
            />
            <path
              className="expertise-flow-line"
              d="M6 70 C 50 70, 50 20, 90 20 S 130 80, 170 80 S 210 24, 234 24"
              stroke="#ceff00"
              strokeWidth={3}
              strokeLinecap="round"
            />
          </svg>
        </div>
      )
    }
    
    // Three layout blocks continuously resizing out of sync — a layout that
    // keeps reflowing around content, rather than a fixed template grid.
    export function BrandVisual() {
      return (
        <div className="flex h-20 w-full flex-col justify-center gap-2 rounded-xl border border-white/10 bg-black/20 p-4">
          <div className="expertise-block-a h-2 rounded-full bg-kine-primary/60" />
          <div className="expertise-block-b h-2 rounded-full bg-kine-primary/40" />
          <div className="expertise-block-c h-2 rounded-full bg-kine-primary/60" />
        </div>
      )
    }
    
    // A load-progress bar that keeps snapping full then resetting — sub-second
    // loads, on a loop, rather than a static speedometer icon.
    export function PerformanceVisual() {
      return (
        <div className="flex h-20 w-full flex-col justify-center gap-2 rounded-xl border border-white/10 bg-black/20 p-4">
          <div className="h-2 w-full overflow-hidden rounded-full bg-white/5">
            <div className="expertise-fill-bar h-full rounded-full bg-kine-primary" />
          </div>
          <span className="font-mono text-[10px] uppercase tracking-widest text-kine-accent/40">Sub-second load</span>
        </div>
      )
    }
    
    // Four bars oscillating like a ranking/traffic chart that never settles —
    // SEO as an ongoing discipline, not a one-time checklist.
    export function SeoVisual() {
      return (
        <div className="flex h-20 w-full items-end justify-center gap-2 rounded-xl border border-white/10 bg-black/20 p-4">
          <div className="expertise-rank-1 w-3 rounded-t bg-kine-primary/70" />
          <div className="expertise-rank-2 w-3 rounded-t bg-kine-primary/50" />
          <div className="expertise-rank-3 w-3 rounded-t bg-kine-primary/70" />
          <div className="expertise-rank-4 w-3 rounded-t bg-kine-primary/50" />
        </div>
      )
    }
    
    // Two labels crossfading — EN / AR — standing in for bilingual fluency,
    // rather than a static globe icon.
    export function CultureVisual() {
      return (
        <div className="relative flex h-20 w-full items-center justify-center rounded-xl border border-white/10 bg-black/20">
          <span className="expertise-lang-a absolute font-kine-display text-2xl tracking-tight text-kine-primary">EN</span>
          <span className="expertise-lang-b absolute font-kine-display text-2xl tracking-tight text-kine-primary" dir="rtl">
            ع
          </span>
        </div>
      )
    }
    lib/on-scroll.ts
    /**
     * Calls `handler` whenever anything scrolls — the window or any scroll
     * container on the page — and on resize. Returns the unsubscribe.
     *
     * The Kinematics components were written against a Lenis instance's scroll
     * event. They only ever use it as a tick and measure themselves with
     * getBoundingClientRect, so any scroll will do. Listening in the capture
     * phase catches scroll events from every element, since they do not bubble:
     * the same component works pinned to the page or inside a scrolling card.
     */
    export function onScroll(handler: () => void) {
      const options = { capture: true, passive: true } as const;
      window.addEventListener("scroll", handler, options);
      window.addEventListener("resize", handler);
      return () => {
        window.removeEventListener("scroll", handler, options);
        window.removeEventListener("resize", handler);
      };
    }
    styles/kinematics.css
    /* Kinematic Studio's palette and type, for the components ported from its
       site. Namespaced as kine-* so they sit beside this library's own tokens —
       Kinematics calls its off-white "accent", which here is already the blue.
       Import this from the stylesheet that imports Tailwind, so the @theme
       block turns into utilities: bg-kine-bg, text-kine-primary/70,
       font-kine-display and so on. */
    @theme {
      --color-kine-bg: #0f1113;
      --color-kine-primary: #ceff00;
      --color-kine-surface: #2b2e34;
      --color-kine-accent: #e2e8f0;
    
      /* Audiowide for headlines, Geo for body copy. Geo breaks up at small
         sizes, so tiny labels use the plain sans instead. */
      --font-kine-display: "Audiowide", ui-sans-serif, system-ui, sans-serif;
      --font-kine-body: "Geo", ui-sans-serif, system-ui, sans-serif;
    
      /* Morni, the restaurant client in the Emoji Cursor demo. */
      --font-morni-brand: "Modak", ui-sans-serif, system-ui, sans-serif;
      --font-morni-body: "Poppins", ui-sans-serif, system-ui, sans-serif;
    }
    
    /* Manifesto emoji markers — the box grows from square to rectangle as scroll
       reaches it; once mostly grown, the emoji starts rolling, paused until then
       via animation-play-state set inline. */
    @keyframes manifesto-emoji-roll {
      to {
        transform: rotate(360deg);
      }
    }
    
    /* Expertise cards — small, always-looping visuals tying each card's
       animation to its own subject (motion, layout, load speed, ranking,
       language). CSS keyframes rather than GSAP, since they run for as long as
       the card is mounted. */
    @keyframes expertise-dash {
      to {
        stroke-dashoffset: -24;
      }
    }
    .expertise-flow-line {
      stroke-dasharray: 6 6;
      animation: expertise-dash 1s linear infinite;
    }
    
    @keyframes expertise-block-a {
      0%, 100% { width: 85%; }
      50% { width: 45%; }
    }
    @keyframes expertise-block-b {
      0%, 100% { width: 55%; }
      50% { width: 90%; }
    }
    @keyframes expertise-block-c {
      0%, 100% { width: 70%; }
      50% { width: 30%; }
    }
    .expertise-block-a { animation: expertise-block-a 3.2s ease-in-out infinite; }
    .expertise-block-b { animation: expertise-block-b 3.2s ease-in-out infinite 0.3s; }
    .expertise-block-c { animation: expertise-block-c 3.2s ease-in-out infinite 0.6s; }
    
    @keyframes expertise-fill {
      0% { width: 6%; opacity: 0.5; }
      70% { width: 100%; opacity: 1; }
      85% { width: 100%; opacity: 1; }
      100% { width: 6%; opacity: 0.5; }
    }
    .expertise-fill-bar {
      animation: expertise-fill 2.2s cubic-bezier(0.65, 0, 0.35, 1) infinite;
    }
    
    @keyframes expertise-rank-bar-1 { 0%, 100% { height: 30%; } 50% { height: 80%; } }
    @keyframes expertise-rank-bar-2 { 0%, 100% { height: 55%; } 50% { height: 25%; } }
    @keyframes expertise-rank-bar-3 { 0%, 100% { height: 40%; } 50% { height: 95%; } }
    @keyframes expertise-rank-bar-4 { 0%, 100% { height: 70%; } 50% { height: 50%; } }
    .expertise-rank-1 { animation: expertise-rank-bar-1 2.4s ease-in-out infinite; }
    .expertise-rank-2 { animation: expertise-rank-bar-2 2.4s ease-in-out infinite 0.15s; }
    .expertise-rank-3 { animation: expertise-rank-bar-3 2.4s ease-in-out infinite 0.3s; }
    .expertise-rank-4 { animation: expertise-rank-bar-4 2.4s ease-in-out infinite 0.45s; }
    
    @keyframes expertise-lang-cross {
      0%, 45% { opacity: 1; transform: translateY(0); }
      50%, 95% { opacity: 0; transform: translateY(-6px); }
      100% { opacity: 1; transform: translateY(0); }
    }
    @keyframes expertise-lang-cross-alt {
      0%, 45% { opacity: 0; transform: translateY(6px); }
      50%, 95% { opacity: 1; transform: translateY(0); }
      100% { opacity: 0; transform: translateY(6px); }
    }
    .expertise-lang-a { animation: expertise-lang-cross 3.6s ease-in-out infinite; }
    .expertise-lang-b { animation: expertise-lang-cross-alt 3.6s ease-in-out infinite; }

Props

PropTypeDefaultDescription
headingstring"Core Expertise"Section title.
introstring—Line under the title.
itemsExpertiseItem[]5 itemsEach picks a `visual`: motion, brand, performance, seo or culture.