Article Stack
A layout primitive for stacked article cards. Each card sits flush with the next at a consistent gap. Pass snap to turn the stack into a snap-paged reader; pass direction="horizontal" to lay the cards out as a swipeable rail.
The setup. Each card sits flush with its neighbour at a consistent gap.
Turn snap on and the stack pages cleanly to each card edge on scroll.
Switch direction to horizontal for a swipeable rail.
Customize
Layout
vertical
md
none
Cards
Installation
npx shadcn@latest add https://craftbits.dev/r/article-stack.jsonUsage
ArticleStack is a Radix-style compound. The root paints the stack; each child card is an ArticleStack.Section that picks up the snap alignment.
import { ChromeArticleStack as ArticleStack } from "@craft-bits/core";
<ArticleStack snap="start">
<ArticleStack.Section title="Intro">
{/* card body */}
</ArticleStack.Section>
<ArticleStack.Section title="Body">
{/* card body */}
</ArticleStack.Section>
<ArticleStack.Section title="Outro">
{/* card body */}
</ArticleStack.Section>
</ArticleStack>Swap the direction for a horizontal rail:
<ArticleStack direction="horizontal" snap="start" gap="lg">
<ArticleStack.Section className="min-w-[18rem]">…</ArticleStack.Section>
<ArticleStack.Section className="min-w-[18rem]">…</ArticleStack.Section>
</ArticleStack>Skip the kicker by omitting the title prop — render your own heading inside the section instead.
Understanding the component
- Compound parts.
ArticleStackis the stack;ArticleStack.Sectionis a single card. Composing rather than passing an array of items means each card owns its own content and one card can opt out of a feature (e.g. drop the kicker) without a special prop. - direction switches the axis.
vertical(default) stacks the cards top-to-bottom;horizontallays them side-by-side and turns on horizontal overflow so the stack scrolls as a rail. - gap controls the rhythm.
nonecollapses the cards together;sm/md/lgapply the standard spacing scale between siblings. - snap pages cleanly.
nonedisables snapping;startforces every scroll stop to land on a card edge;proximitylets the reader stop mid-card and only snaps if they are close to one. - Section landmarks. Each card renders as a
<section>so assistive tech announces it as a self-contained unit and screen-reader users can jump between cards. - Data hooks. The root carries
data-cb-article-stack,data-direction,data-gap,data-snap, anddata-section-countso consumers can target nested regions without re-deriving props. Sections carrydata-section-idfor QA hooks.
Variants
Snap-paged reader
<ArticleStack snap="start" gap="lg">
<ArticleStack.Section title="Page 1">…</ArticleStack.Section>
<ArticleStack.Section title="Page 2">…</ArticleStack.Section>
<ArticleStack.Section title="Page 3">…</ArticleStack.Section>
</ArticleStack>Horizontal rail
<ArticleStack direction="horizontal" snap="proximity" gap="md">
<ArticleStack.Section className="min-w-[18rem]">…</ArticleStack.Section>
<ArticleStack.Section className="min-w-[18rem]">…</ArticleStack.Section>
</ArticleStack>No gap, edge-to-edge
<ArticleStack gap="none">
<ArticleStack.Section>…</ArticleStack.Section>
<ArticleStack.Section>…</ArticleStack.Section>
</ArticleStack>Props
ArticleStack
| Prop | Type | Default | Description |
|---|---|---|---|
direction | 'vertical' | 'horizontal' | 'vertical' | Axis of the stack. Horizontal turns on overflow-x: auto. |
gap | 'none' | 'sm' | 'md' | 'lg' | 'md' | Spacing between sibling sections. |
snap | 'none' | 'start' | 'proximity' | 'none' | Scroll-snap mode. start is mandatory; proximity is opportunistic. |
className | string | — | Merged onto the rendered <div> via cn(). |
...rest | HTMLAttributes<HTMLElement> | — | Any other <div> attribute. |
ArticleStack.Section
| Prop | Type | Default | Description |
|---|---|---|---|
title | ReactNode | — | Optional kicker rendered above the card body. |
name | string | — | Stable identifier surfaced as data-section-id. |
className | string | — | Merged onto the rendered <section> via cn(). |
...rest | HTMLAttributes<HTMLElement> | — | Any other <section> attribute. |
Accessibility
- Each
ArticleStack.Sectionrenders a<section>landmark — screen readers expose it as a self-contained unit and assistive tech can jump between cards. - The optional kicker is a
<header>so its label is announced as a section header rather than free-floating text. - The dividing hairline next to the kicker is
aria-hidden— decorative only. - No motion is applied by the stack. Reduced-motion respect is the responsibility of the card body, which has no enforced animation.
- Color contrast: the kicker uses
--cb-fg-subtleand the hairline uses--cb-border-muted. Both pass WCAG AA on every default surface.
Credits
- Extracted from:
craftingattention(app/src/lessons/primitives/chrome/ArticleStack.tsx). The original was a phase-aware lesson renderer that owned aPhaseRibbon, a scroll-into-view side effect, and aLessonDisplayModecontext dependency. craft-bits strips every project-specific concern and keeps the underlying intent — a stacked-card layout that supports snap-paging and a horizontal direction. The compoundArticleStack.Sectionshape mirrors the original API minus thephase/currentPhaseprops.