Layout
Capability Bento
One featured card and four small ones on dashed borders, scaling up and fading in one after another as they reach the middle of the screen.
Capabilities
Interactive web design in Kuwait, end to end: Next.js engineering, GSAP-driven motion design, and Awwwards-level web development — built for sub-second load times and 60fps animation, not templates.
Awwwards-level interactive design for Kuwait brands — bespoke layouts and motion, never a templated theme.
Next.js builds engineered for sub-second load times, from the first paint to the last interaction.
GSAP and Lenis-driven scroll animation — every interaction hand-tuned, zero dropped frames.
Real AI features wired into the product itself — not a canned chatbot bolted on afterward.
Image optimization, code-splitting, and edge rendering, built in from the first commit.
Scroll the page — each card scales up and fades in as it reaches the middle of the screen
import CapabilityBento from "@/components/ui/capability-bento";
export default function CapabilityBentoDemo() {
return (
<div className="w-full min-w-0 space-y-3">
<div className="w-full overflow-hidden rounded-card border border-line">
<CapabilityBento />
</div>
<p className="text-center text-xs text-faint">
Scroll the page — each card scales up and fades in as it reaches the middle of the screen
</p>
</div>
);
}Installation
1. Install the dependencies.
terminal npm install2. Add the
cnhelper, 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. Copy the component into your project.
components/ui/capability-bento.tsx 'use client' import { useEffect, useRef } from 'react' import { onScroll } from '@/lib/on-scroll' import { useIsMobile } from '@/lib/use-is-mobile' export interface Capability { title: string description: string span: string featured: boolean } interface CapabilityCardProps { cap: Capability index: number } // Each capability card scales from 60% → 100% + opacity 0 → 1 as its center // approaches the viewport middle, recomputed continuously off real scroll // (same pattern as StackSection and WorkSection) rather than // an IntersectionObserver — with the default single `threshold: 0`, that API // only ever fires at the two boundary crossings (entering and fully // exiting), never while the card is actually scrolling through the middle // of the viewport, which is exactly when this "approaching the middle" math // needs to keep re-evaluating. That mismatch is what read as the card // disappearing while centered and only "loading" in right at the edges. export function CapabilityCard({ cap, index }: CapabilityCardProps) { const cardRef = useRef<HTMLDivElement>(null) const isMobile = useIsMobile() // Fraction of the 0..1 arrival ramp each later card's own start is pushed // back by, so cards still stagger in one after another rather than all // reaching full opacity at the same scroll position. const staggerDelay = index * 0.08 useEffect(() => { const card = cardRef.current if (!card) return // Mobile: skip the scroll-driven scale/opacity ramp entirely — every // card renders fully visible at its resting size from the start (see // the inline style fallback below too, which covers the instant before // this effect's mount-time correction on a mobile first paint). if (isMobile) { card.style.opacity = '1' card.style.transform = 'scale(1)' return } const update = () => { const rect = card.getBoundingClientRect() const viewportH = window.innerHeight const cardCenter = rect.top + rect.height / 2 const raw = Math.min(1, Math.max(0, (viewportH - cardCenter) / (viewportH * 0.5))) const arrival = Math.min(1, Math.max(0, (raw - staggerDelay) / (1 - staggerDelay))) card.style.opacity = String(arrival) card.style.transform = `scale(${0.6 + 0.4 * arrival})` } update() return onScroll(update) }, [staggerDelay, isMobile]) return ( <div ref={cardRef} className={`group flex flex-col justify-between gap-4 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 ${ cap.span } ${cap.featured ? 'border-kine-primary/60 bg-kine-primary/[0.03]' : 'border-kine-primary/30 bg-transparent'}`} style={{ opacity: 0, transform: 'scale(0.6)', }} > <span className="font-kine-display text-lg uppercase tracking-tight text-kine-primary md:text-2xl"> {cap.title} </span> <p className="font-kine-body text-sm text-kine-accent/70 md:text-base">{cap.description}</p> </div> ) } // One featured 2x2 card plus four 1x1 cards, filling a 4-column x 2-row // grid exactly (see each item's `span`). const CAPABILITIES: Capability[] = [ { title: 'Web Design', description: 'Awwwards-level interactive design for Kuwait brands — bespoke layouts and motion, never a templated theme.', span: 'sm:col-span-2 lg:col-span-2 lg:row-span-2', featured: true, }, { title: 'Web Development', description: 'Next.js builds engineered for sub-second load times, from the first paint to the last interaction.', span: '', featured: false, }, { title: 'Motion Design', description: 'GSAP and Lenis-driven scroll animation — every interaction hand-tuned, zero dropped frames.', span: '', featured: false, }, { title: 'AI Integrations', description: 'Real AI features wired into the product itself — not a canned chatbot bolted on afterward.', span: '', featured: false, }, { title: 'Performance Engineering', description: 'Image optimization, code-splitting, and edge rendering, built in from the first commit.', span: '', featured: false, }, ] interface CapabilityBentoProps { heading?: string intro?: string capabilities?: Capability[] } /** The capabilities bento: dashed-border cards that scale up and fade in, * one after another, as they reach the middle of the viewport. */ export default function CapabilityBento({ heading = 'Capabilities', intro = 'Interactive web design in Kuwait, end to end: Next.js engineering, GSAP-driven motion design, and Awwwards-level web development — built for sub-second load times and 60fps animation, not templates.', capabilities = CAPABILITIES, }: CapabilityBentoProps) { return ( <section className="bg-[#0f1113] px-6 py-24 md:py-32"> <div className="mx-auto flex max-w-6xl flex-col gap-12"> <div className="flex flex-col items-center gap-6 text-center"> <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"> {capabilities.map((cap, i) => ( <CapabilityCard key={cap.title} cap={cap} index={i} /> ))} </div> </div> </section> ) }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); }; }lib/use-is-mobile.ts "use client"; import { useEffect, useState } from "react"; const QUERY = "(max-width: 767px)"; /** True below Tailwind's `md` breakpoint (767px). Starts `false` on the * server/first paint (no `matchMedia` there) and corrects on mount — the * same convention as usePrefersReducedMotion. For components that render a * genuinely different layout on mobile, not just a slowed-down one. */ export function useIsMobile() { const [isMobile, setIsMobile] = useState(false); useEffect(() => { const query = window.matchMedia(QUERY); setIsMobile(query.matches); const handleChange = (e: MediaQueryListEvent) => setIsMobile(e.matches); query.addEventListener("change", handleChange); return () => query.removeEventListener("change", handleChange); }, []); return isMobile; }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
| Prop | Type | Default | Description |
|---|---|---|---|
| heading | string | "Capabilities" | Section title. |
| intro | string | — | Line under the title. |
| capabilities | Capability[] | 5 items | Each has a `title`, `description`, grid `span` and `featured` flag. |