ScrollVars / fx
docs · demo · bench · llms.txt · GitHub

Case-study rail

Oversized project cards traverse a pinned stage with --sv-pin; long content returns to a readable list.

Use it for: Studio portfolios and selected-work sections. Container units enable the animation; older engines get a native horizontal rail.
Knobs: title, projects, href, --work-accent; cards are non-interactive, the optional CTA stays after the rail

Independent thinking · tangible results

Selected work

01Brand / Digital

Reframe the ordinary

A clear identity with room for unexpected details.

02Product / Strategy

Built for the next chapter

A product story that makes complex work feel effortless.

03Culture / Commerce

Make every detail count

An editorial presence with the confidence to stay simple.

Install: npx scrollvars add case-study-rail
Styles: import 'scrollvars/styles/pin.css'

// ScrollVars fx · case-study-rail
// Requires: npm i scrollvars · import 'scrollvars/styles/pin.css'
'use client'
import * as React from 'react'
import { Track } from 'scrollvars/react'

const css = `
.sv-casework { --work-accent: #c5f36b; color: #f3f0e8; background: #171a16; }
.sv-casework .work-head { padding: 48px clamp(24px, 5vw, 72px) 24px; }
.sv-casework h2 { font-size: clamp(36px, 7vw, 88px); letter-spacing: -.055em; line-height: 1; margin: 12px 0; }
.sv-casework .work-stage { display: flex; align-items: center; }
.sv-casework .work-fit { width: 100%; }
.sv-casework .work-rail { display: flex; gap: 24px; width: max-content; padding: 24px; box-sizing: border-box; }
.sv-casework .work-card { flex: none; width: min(70vw, 800px); padding: clamp(20px, 4vw, 48px); box-sizing: border-box; border: 1px solid #ffffff30; border-radius: 12px; }
.sv-casework .work-art { height: clamp(100px, 30vh, 280px); border-radius: 8px; overflow: hidden; background: radial-gradient(circle at 65% 40%, var(--work-accent) 0 18%, transparent 18.5%), repeating-linear-gradient(135deg, #303d28 0 40px, #263020 40px 80px); }
.sv-casework .work-art img { display: block; width: 100%; height: 100%; object-fit: cover; }
.sv-casework .work-meta { display: flex; justify-content: space-between; margin: 22px 0 12px; color: var(--work-accent); font: 12px monospace; text-transform: uppercase; }
.sv-casework h3 { font-size: clamp(28px, 5vw, 54px); line-height: 1.05; margin: 0 0 12px; letter-spacing: -.04em; }
.sv-casework .work-card:nth-child(2n) { --work-accent: #9ee4ee; }
.sv-casework .work-card:nth-child(3n) { --work-accent: #ff9d73; }
.sv-casework .work-card p { max-width: 44ch; margin: 0; line-height: 1.5; }
.sv-casework .work-footer { padding: 28px; text-align: right; }
.sv-casework .work-footer a { color: inherit; }
/* This rail starts at its first card. cqw measures the real stage, not the page. */
@supports (width: 1cqw) {
  .sv-casework .work-stage { container-type: inline-size; }
  .sv-casework .work-card { width: min(80cqw, 800px); }
  .sv-on .sv-casework .work-rail { transform: translateX(calc(var(--sv-pin, 0) * min(100cqw - 100%, 0px))); will-change: transform; }
}
html:not(.sv-on) .sv-casework .work-rail,
.sv-casework [data-sv-flow] .work-rail,
.sv-casework [data-sv-off] .work-rail { width: auto; flex-wrap: wrap; transform: none; will-change: auto; }
html:not(.sv-on) .sv-casework .work-card,
.sv-casework [data-sv-flow] .work-card,
.sv-casework [data-sv-off] .work-card { width: 100%; }
/* below the cqw floor: wrap like no-JS instead of a keyboard-unreachable
   horizontal scroller (WCAG 2.1.1, no tabindex on the scroller). */
@supports not (width: 1cqw) {
  .sv-casework > .sv { height: auto !important; }
  .sv-casework .work-stage { height: auto; position: static; }
  .sv-casework .work-rail { width: auto; flex-wrap: wrap; transform: none; will-change: auto; }
  .sv-casework .work-card { width: 100%; }
}
@media (prefers-reduced-motion: reduce) {
  .sv-on .sv-casework .work-rail { width: auto; flex-wrap: wrap; transform: none; will-change: auto; }
  .sv-casework .work-card { width: 100%; }
}
/* the same under html[data-sv-motion="reduce"], the site's own switch */
.sv-on:where([data-sv-motion="reduce"]) .sv-casework .work-rail { width: auto; flex-wrap: wrap; transform: none; will-change: auto; }
:where([data-sv-motion="reduce"]) .sv-casework .work-card { width: 100%; }
`

export interface CaseStudy {
  title: string
  summary: string
  category: string
  image?: { src: string; alt: string }
}

export function CaseStudyRail({ title = 'Selected work', projects, href, className, nonce }: {
  title?: string
  projects: CaseStudy[]
  /** One link after the rail; the moving cards are deliberately non-interactive. */
  href?: string
  className?: string
  nonce?: string
}) {
  return (
    <section className={className ? 'sv-casework ' + className : 'sv-casework'}>
      <style nonce={nonce} dangerouslySetInnerHTML={{ __html: css }} />
      <header className="work-head"><small>Independent thinking · tangible results</small><h2>{title}</h2></header>
      <Track pin={Math.max(projects.length, 1) * 100 + 'vh'}>
        <div className="sv-stage work-stage"><div className="work-fit" data-sv-fit="">
          <div className="work-rail">
            {projects.map((project, i) => <article className="work-card" key={i}>
              <div className="work-art">{project.image && <img src={project.image.src} alt={project.image.alt} />}</div>
              <div className="work-meta"><span>{String(i + 1).padStart(2, '0')}</span><span>{project.category}</span></div>
              <h3>{project.title}</h3><p>{project.summary}</p>
            </article>)}
          </div>
        </div></div>
      </Track>
      {href && <footer className="work-footer"><a href={href}>Explore all projects →</a></footer>}
    </section>
  )
}

Engine: npm i scrollvars, 12.9 KB gzip as ESM (this page's fx/sv.js IIFE: 13.1 KB). All effects respect prefers-reduced-motion and render complete without JS.