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.

Intro

The setup. Each card sits flush with its neighbour at a consistent gap.

Body

Turn snap on and the stack pages cleanly to each card edge on scroll.

Outro

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.json

Usage

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

  1. Compound parts. ArticleStack is the stack; ArticleStack.Section is 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.
  2. direction switches the axis. vertical (default) stacks the cards top-to-bottom; horizontal lays them side-by-side and turns on horizontal overflow so the stack scrolls as a rail.
  3. gap controls the rhythm. none collapses the cards together; sm / md / lg apply the standard spacing scale between siblings.
  4. snap pages cleanly. none disables snapping; start forces every scroll stop to land on a card edge; proximity lets the reader stop mid-card and only snaps if they are close to one.
  5. 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.
  6. Data hooks. The root carries data-cb-article-stack, data-direction, data-gap, data-snap, and data-section-count so consumers can target nested regions without re-deriving props. Sections carry data-section-id for 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

PropTypeDefaultDescription
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.
classNamestringMerged onto the rendered <div> via cn().
...restHTMLAttributes<HTMLElement>Any other <div> attribute.

ArticleStack.Section

PropTypeDefaultDescription
titleReactNodeOptional kicker rendered above the card body.
namestringStable identifier surfaced as data-section-id.
classNamestringMerged onto the rendered <section> via cn().
...restHTMLAttributes<HTMLElement>Any other <section> attribute.

Accessibility

  • Each ArticleStack.Section renders 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-subtle and 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 a PhaseRibbon, a scroll-into-view side effect, and a LessonDisplayMode context 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 compound ArticleStack.Section shape mirrors the original API minus the phase / currentPhase props.