Case Study Carousel

Horizontal case-study carousel. Carousel Desktop (content + image card) is used on /, /work, and /about. Carousel Mobile (image-dominant card with title overlay and success-metric sticker) renders below the lg breakpoint on those surfaces and in the home hero's right column.

Carousel Desktop

Default layout (variant="full"). 960px-wide cards with content on the left and image on the right. Shown with mock data.

Carousel Mobile

Compact image-dominated cards (variant="hero"). Used as the mobile layout for the case-studies module and inside the home-page hero's right column. Keeps the same rotating border animation on the active card. On first load, hero variants render a static first slide until the client can position the looping track; if that mount step takes longer, the mobile shimmer treatment can cover the gap so the clone slide never flashes.

Loading skeleton — desktop

Shimmer state that mirrors the desktop carousel's active card. Used by WorkCarousel as a fade-in overlay during category filter transitions so the carousel never flash-swaps.

Loading skeleton — mobile

Mirrors the mobile hero card — aspect-square shimmer plus dots and arrow-button placeholders below.

Filter-aware wrapper (WorkCarousel)

WorkCarousel is the client wrapper used on /work. It reads the ?category= URL param (written by CategoryFilters), filters the case studies client-side, and fades the skeleton overlay in for 500ms during the transition so new cards never flash-replace old ones. If a filter produces zero matches, it renders a centered empty-state message instead.

View full version

Props

caseStudies

CaseStudy[] (required)

Array of case study data from Sanity CMS.

variant

"full" | "hero" (default "full")

"full" is the responsive Case Studies module: Carousel Mobile below lg, Carousel Desktop at lg+. "hero" forces Carousel Mobile at all widths — used inside the home-page hero, wrapped in a lg:block parent so it only shows on desktop.

title

string (default: "Case Studies", ignored when forced to hero)

Section heading. Set to empty string to hide. Hero-forced renders never draw a title.

subtitle

string (optional)

Description text below heading. Full variant only.

showViewAll

boolean (default false)

Full variant only. Renders a "View all case studies" button below the carousel.

Card styling

.card-carousel-item

var(--gradient-card-surface) — a neutral 5% wash of --foreground ending on transparent — plus a --border-subtle hairline. No accent anywhere. Shared with .card-blog, .card-featured and .card-faq, so never edit the base rule.

.card-carousel-active

Rotating conic-gradient border animation applied only to the active (centered) card in both variants.

.carousel-edge-mask

Fades the track's own pixels at both clip edges (≥1280px), so cards and ghosts dissolve while the ground behind them — the paper sections' draft grid on the home page — stays visible.

It replaced two painted overlays that flooded flat --background inward: identical card fade, but they erased whatever the section ground was drawing in a 128px strip down each side. Wraps only the track — the arrows are siblings outside it and stay solid.

.card-ghost

Covers every non-active card. Opaque, mixed from --foreground against --background, so it resolves per surface with no variants: soft tan on a paper ground, soft charcoal on the dark page.

It replaced dimming the neighbours with opacity, which only worked while the ground was as dark as the card — fading a near-black card toward cream passes through grey. It crossfades rather than swapping, because the active index updates before the spring settles.

.surface-paper .card-carousel-item

background: var(--gradient-card-surface), var(--surface-ink-panel);

The card's wash ends on transparent, so on the cream home-page ground the paper would show through and tint it. Layering the identical wash over the opaque --surface-ink-panel reproduces the dark-page composite exactly, so the card stays dark. A matching :hover rule exists at higher specificity — without it the shared hover rule ties on specificity and drops the opaque backing mid-hover.

.carousel-arrow

background: var(--background); 48px circle at left-14 / right-14

Solid rather than a translucent wash: the arrows sit over the neighbour slot, not on the section ground, so a translucent fill would take on whatever is behind it — on a light section that meant an ink-tinted arrow over a dark card. A --background fill always contrasts with the --foreground icon on it, so it reads on whatever it overlays.

Shimmer primitive

Every shimmer bar in the skeleton is a local `ShimmerBar` — a rounded div with `bg-foreground/10 animate-pulse`. Because it's keyed off `--foreground` it follows whichever surface class is in scope, so the same bar works on the dark page and on a paper ground.

tsx
function ShimmerBar({ className }: { className?: string }) {
  return (
    <div
      className={cn(
        'rounded-sm bg-foreground/10 animate-pulse',
        className
      )}
    />
  )
}

Usage

tsx
import { CaseStudyCarousel } from '@/components/sections/CaseStudyCarousel'
import { CaseStudyGridSkeleton } from '@/components/sections/CaseStudyGridSkeleton'

// Full carousel on /work and /home
<CaseStudyCarousel caseStudies={caseStudies} showViewAll subtitle="…" />

// Compact hero variant — wrap in an lg:block container
<div className="hidden w-full lg:block lg:max-w-[540px]">
  <CaseStudyCarousel caseStudies={caseStudies} variant="hero" />
</div>

// Loading skeleton (auto-picks variant by viewport)
<CaseStudyGridSkeleton />

Import

tsx
import { CaseStudyCarousel } from '@/components/sections/CaseStudyCarousel'
import { CaseStudyGridSkeleton } from '@/components/sections/CaseStudyGridSkeleton'
import { WorkCarousel } from '@/components/sections/WorkCarousel'

Related files

src/components/sections/CaseStudyCarousel.tsxsrc/components/sections/CaseStudyGridSkeleton.tsxsrc/components/sections/WorkCarousel.tsxsanity/schemas/caseStudy.tssrc/lib/queries.ts