LAB/ Tanishk Interface Lab
MotionstableReactCSS scroll-snapIntersectionObserver

Soft Snap Cards

Full-width cards that settle into frame without hijacking your scroll.

Live preview

Field journal

Notes from shipped work

Wayfinding in dense tables

Field note · 6 min

Sorting, density and the cost of a sticky header: what survived a week of real use.

The shape of a review step

Field note · 9 min

Why the accept button is the least interesting part of human-in-the-loop tooling.

Quiet motion in editorial

Field note · 4 min

Ambient fields, slow reveals and the discipline of leaving the page mostly still.

Citation as interface

Field note · 7 min

Progressive disclosure for sources, and why confidence needs a number and a word.

SettleFrameSix cards

Scroll inside the lane, then stop mid-card

01What it does

CSS scroll-snap proximity, and nothing more aggressive than that: when a gesture ends near a card, the browser settles it; when you keep scrolling, it never pulls you back. This is the framing behaviour large editorial cards need — case studies, changelog entries, media features — gated to fine pointers on wide screens, with reduced-motion readers left untouched.

In this previewSix plausible case-study cards in a bounded lane. Scroll with the wheel or drag the scrollbar and watch a card frame itself — switch settling off to feel the difference.

02Install

Registry install

Works with the shadcn CLI in any project that has a components.json. The component lands in components/lab/soft-snap/ with its stylesheet beside it.

npx shadcn@latest add <lab-origin>/r/soft-snap.json

No npm packages are installed — the component is self-contained. Prefer manual copying? Every file is below under Source.

  • components/lab/soft-snap/soft-snap.tsxcomponent
  • components/lab/soft-snap/soft-snap.cssstyles

Machine-readable item: /r/soft-snap.json ↗

03Usage

soft-snap.usage.tsx
import { SoftSnap, SoftSnapItem } from "@/components/lab/soft-snap/soft-snap";
import "./soft-snap-overrides.css"; // optional page-level styling

export function WorkLane({ entries }: { entries: Entry[] }) {
return (
<SoftSnap mode="proximity" align="start" gap={28} className="h-[70vh]">
{entries.map((entry) => (
<SoftSnapItem key={entry.slug}>
<article className="h-[62vh]">{/* card */}</article>
</SoftSnapItem>
))}
</SoftSnap>
);
}

04Props

Props accepted by Soft Snap Cards
PropTypeDefaultNotes
mode"off" | "proximity" | "mandatory" | "assist""proximity"Settle strength. `assist` is the only JS mode: a scroll-end nudge you can cancel by moving.
align"start" | "center" | "nearest""start"Where a settled card comes to rest.
gapnumber24Space between cards in pixels.
gate"fine" | "always""fine"fine applies settling only on wide, fine-pointer, no-preference screens.

05Accessibility

  • Snapping only ever assists a gesture that already ended near a card; the lane scrolls normally at every scroll speed.
  • Disabled entirely for reduced-motion readers and touch screens, where native momentum scrolling is already the right behaviour.
  • Cards remain ordinary tab stops; scrolling a focused card into view triggers the browser’s own scroll behaviour, not a trap.

06Source

2 files, 249 lines. Nothing private, no configuration required.

components/lab/soft-snap/soft-snap.tsx— press enter to show or hide the source
tsx
"use client";

import {
useEffect,
useRef,
type CSSProperties,
type ReactNode,
} from "react";
import "./soft-snap.css";

export type SoftSnapMode = "off" | "proximity" | "mandatory" | "assist";
export type SoftSnapAlign = "start" | "center" | "nearest";
export type SoftSnapGate = "fine" | "always";

type SoftSnapProps = {
/** Settle strength. `assist` is the only JS mode — a cancelable nudge. */
mode?: SoftSnapMode;
align?: SoftSnapAlign;
/** Space between items, in pixels. */
gap?: number;
/**
* `fine` (default) applies settling only on wide, fine-pointer,
* no-preference screens. `always` applies it wherever it is supported.
*/
gate?: SoftSnapGate;
/** Distance in pixels from the lane's top that a settled item keeps. */
offset?: number;
className?: string;
children: ReactNode;
};

const ASSIST_TOLERANCE = 8;
const ASSIST_MAX_DISTANCE = 0.45;
const ASSIST_MIN_VISIBLE = 0.55;
const ASSIST_IDLE_MS = 150;

/**
* SOFT SNAP — large cards settle into frame without scrolljacking.
*
* CSS scroll-snap proximity does the work: when a gesture ends near an
* item the browser settles it, and when the reader keeps scrolling the
* lane never pulls them back. `assist` mode adds a small, cancelable
* nudge for browsers or layouts where proximity alone is too weak —
* it waits for scrolling to stop, only fires when an item is already
* mostly in view and close to its resting place, and is cancelled by
* any new input.
*/
export function SoftSnap({
mode = "proximity",
align = "start",
gap = 24,
gate = "fine",
offset = 0,
className,
children,
}: SoftSnapProps) {
const ref = useRef<HTMLDivElement>(null);

useEffect(() => {
if (mode !== "assist") return;
const lane = ref.current;
if (!lane) return;

const fine = window.matchMedia("(pointer: fine)");
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)");
if (!fine.matches || reduced.matches) return;

let timer: number | undefined;
let cancelled = false;

const settle = () => {
if (cancelled || !lane.isConnected) return;
const box = lane.getBoundingClientRect();
if (box.height === 0) return;
const items = Array.from(
lane.querySelectorAll<HTMLElement>("[data-snap-item]"),
);
if (items.length === 0) return;

let best: HTMLElement | null = null;
let bestDistance = Number.POSITIVE_INFINITY;
for (const item of items) {
const rect = item.getBoundingClientRect();
const distance = align === "center"
? Math.abs(
rect.top + rect.height / 2 - (box.top + box.height / 2),
)
: Math.abs(rect.top - box.top - offset);
if (distance < bestDistance) {
bestDistance = distance;
best = item;
}
}
if (!best) return;

const rect = best.getBoundingClientRect();
const visible =
Math.min(rect.bottom, box.bottom) - Math.max(rect.top, box.top);
const ratio = visible / Math.min(rect.height, box.height);
if (ratio < ASSIST_MIN_VISIBLE) return;
if (bestDistance > box.height * ASSIST_MAX_DISTANCE) return;
if (bestDistance <= ASSIST_TOLERANCE) return;

const target =
align === "center"
? rect.top - box.top - (box.height - rect.height) / 2
: rect.top - box.top - offset;
lane.scrollTo({ top: lane.scrollTop + target, behavior: "smooth" });
};

const onScroll = () => {
if (timer) window.clearTimeout(timer);
timer = window.setTimeout(settle, ASSIST_IDLE_MS);
};
const onInput = () => {
if (timer) window.clearTimeout(timer);
};

lane.addEventListener("scroll", onScroll, { passive: true });
lane.addEventListener("wheel", onInput, { passive: true });
lane.addEventListener("touchstart", onInput, { passive: true });
lane.addEventListener("pointerdown", onInput);

return () => {
cancelled = true;
if (timer) window.clearTimeout(timer);
lane.removeEventListener("scroll", onScroll);
lane.removeEventListener("wheel", onInput);
lane.removeEventListener("touchstart", onInput);
lane.removeEventListener("pointerdown", onInput);
};
}, [mode, align, offset]);

return (
<div
ref={ref}
className={`lab-soft-snap${className ? ` ${className}` : ""}`}
data-mode={mode}
data-align={align}
data-gate={gate}
style={{ "--snap-gap": `${gap}px`, "--snap-offset": `${offset}px` } as CSSProperties}
>
{children}
</div>
);
}

/**
* SOFT SNAP ITEM — one settle target. Items keep their own layout;
* only their snapping alignment is owned by the lane.
*/
export function SoftSnapItem({
className,
children,
}: {
className?: string;
children: ReactNode;
}) {
return (
<div
className={`lab-soft-snap-item${className ? ` ${className}` : ""}`}
data-snap-item
>
{children}
</div>
);
}
components/lab/soft-snap/soft-snap.css— press enter to show or hide the source
css
/* ============================================================
SOFT SNAP — self-contained styles.
Every token is scoped with a fallback, so the component works in
any codebase without the Lab's stylesheet.
============================================================ */

.lab-soft-snap {
--soft-snap-gap: var(--snap-gap, 24px);
--soft-snap-offset: var(--snap-offset, 0px);
height: 100%;
overflow-y: auto;
overscroll-behavior: contain;
scroll-padding-top: var(--soft-snap-offset);
-webkit-overflow-scrolling: touch;
}

.lab-soft-snap-item {
display: flow-root;
}

.lab-soft-snap-item + .lab-soft-snap-item {
margin-top: var(--soft-snap-gap);
}

.lab-soft-snap-item:last-child {
margin-bottom: var(--soft-snap-gap);
}

/* Alignment per item — always applied, in every mode. */
.lab-soft-snap[data-align="start"] .lab-soft-snap-item {
scroll-snap-align: start;
scroll-margin-top: var(--soft-snap-offset);
}

.lab-soft-snap[data-align="center"] .lab-soft-snap-item {
scroll-snap-align: center;
}

.lab-soft-snap[data-align="nearest"] .lab-soft-snap-item {
scroll-snap-align: nearest;
}

/* Always-on gating for hosts that want the behaviour everywhere. */
.lab-soft-snap[data-gate="always"][data-mode="proximity"] {
scroll-snap-type: y proximity;
}

.lab-soft-snap[data-gate="always"][data-mode="mandatory"] {
scroll-snap-type: y mandatory;
}

.lab-soft-snap[data-mode="off"] {
scroll-snap-type: none;
}

/*
* The gated default: settling is an enhancement for wide screens with
* a fine pointer and no reduced-motion preference. Touch keeps its
* native momentum scrolling untouched.
*/
@media (min-width: 64rem) and (pointer: fine) and (prefers-reduced-motion: no-preference) {
.lab-soft-snap[data-gate="fine"][data-mode="proximity"] {
scroll-snap-type: y proximity;
}

.lab-soft-snap[data-gate="fine"][data-mode="mandatory"] {
scroll-snap-type: y mandatory;
}
}

/* Assist mode never uses CSS snapping — the JS nudge is the settle. */
.lab-soft-snap[data-mode="assist"] {
scroll-snap-type: none;
}

@media (prefers-reduced-motion: reduce) {
.lab-soft-snap {
scroll-behavior: auto;
}
}