Article Rail
Give your journal a place on the homepage. Image-led article cards with useful metadata and a touch-friendly reading rail.
A little perspective.
Browse the journal. Open an article.
'use client';
import { useEffect, useId, useRef, useState } from 'react';
import type { ReactNode } from 'react';
import './article-rail.css';
export interface ArticleRailItem {
id: string;
title: string;
category: string;
href: string;
readTime?: string;
date?: string;
/** Decorative article artwork without nested controls. */
media: ReactNode;
}
export interface ArticleRailProps {
items: ArticleRailItem[];
title?: string;
className?: string;
}
/** A touch-scrollable journal rail with ordinary article links and bounded paging controls. */
export function ArticleRail({ items, title = 'Ideas worth sharing', className = '' }: ArticleRailProps) {
const instance = useId();
const viewport = useRef<HTMLDivElement>(null);
const track = useRef<HTMLUListElement>(null);
const [edges, setEdges] = useState({ start: true, end: items.length < 2 });
useEffect(() => {
const element = viewport.current;
if (!element) return;
let frame = 0;
function measure() {
if (!element) return;
const maximum = Math.max(0, element.scrollWidth - element.clientWidth);
const position = Math.max(0, element.scrollLeft);
// Scroll snapping aligns the first card after the viewport's focus-ring inset.
const startInset = Number.parseFloat(getComputedStyle(element).paddingLeft) || 0;
const next = { start: position <= startInset + 2, end: position >= maximum - 2 };
setEdges(previous => previous.start === next.start && previous.end === next.end ? previous : next);
}
function scheduleMeasure() {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(measure);
}
const observer = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(scheduleMeasure);
observer?.observe(element);
if (track.current) observer?.observe(track.current);
element.addEventListener('scroll', scheduleMeasure, { passive: true });
window.addEventListener('resize', scheduleMeasure);
measure();
return () => {
cancelAnimationFrame(frame);
observer?.disconnect();
element.removeEventListener('scroll', scheduleMeasure);
window.removeEventListener('resize', scheduleMeasure);
};
}, [items.length]);
function move(direction: -1 | 1) {
const element = viewport.current;
const cards = track.current?.children;
if (!element || !cards?.length) return;
const first = cards[0] as HTMLElement;
const second = cards[1] as HTMLElement | undefined;
const step = second ? second.offsetLeft - first.offsetLeft : first.offsetWidth;
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
element.scrollBy({ left: direction * step, behavior: reducedMotion ? 'auto' : 'smooth' });
}
if (!items.length) return null;
return <section className={`ac-article-rail ${className}`.trim()} aria-labelledby={`${instance}-heading`}>
<header className="ac-article-rail-header"><div><span className="ac-article-rail-eyebrow">THE JOURNAL</span><h2 id={`${instance}-heading`}>{title}</h2></div><div className="ac-article-rail-controls"><button type="button" aria-label="Previous articles" aria-controls={`${instance}-viewport`} disabled={edges.start} onClick={() => move(-1)}><span aria-hidden="true">←</span></button><button type="button" aria-label="Next articles" aria-controls={`${instance}-viewport`} disabled={edges.end} onClick={() => move(1)}><span aria-hidden="true">→</span></button></div></header>
<div className="ac-article-rail-viewport" ref={viewport} id={`${instance}-viewport`}>
<ul className="ac-article-rail-track" ref={track}>{items.map((item, index) => <li className="ac-article-rail-item" key={item.id}><article><a className="ac-article-rail-link" href={item.href} aria-labelledby={`${instance}-article-${index}`} aria-describedby={`${instance}-meta-${index}`}><div className="ac-article-rail-media" aria-hidden="true" ref={node => { if (node) node.setAttribute('inert', ''); }}>{item.media}<span className="ac-article-rail-link-arrow" aria-hidden="true">↗</span></div><div className="ac-article-rail-meta" id={`${instance}-meta-${index}`}><span>{item.category}</span><span>{[item.date, item.readTime].filter(Boolean).join(' · ')}</span></div><h3 id={`${instance}-article-${index}`}><span>{item.title}</span></h3></a></article></li>)}</ul>
</div>
</section>;
}
Installation
Run this in your shadcn-configured React project. The component and stylesheet are added together.
npx shadcn@latest add http://localhost:5173/r/article-rail.jsonThis command uses the current site address. For localhost, keep this preview running on the same computer. Files follow your configured components alias.
Usage
Import the component and make the details your own.
import { ArticleRail } from '@/components/anticraft/ArticleRail';
export function Example() {
return (
<ArticleRail
title="A little perspective."
items={[
{
id: 'next-chapter', title: 'Designing for the next chapter',
category: 'Perspective', readTime: '4 min read',
media: <img src="/images/next-chapter.jpg" alt="" />,
href: '/journal/next-chapter',
},
{
id: 'clear-story', title: 'A useful website starts with a clear story',
category: 'Process', readTime: '6 min read',
media: <img src="/images/clear-story.jpg" alt="" />,
href: '/journal/clear-story',
},
{
id: 'small-details', title: 'Why small details build trust',
category: 'Field notes', readTime: '3 min read',
media: <img src="/images/small-details.jpg" alt="" />,
href: '/journal/small-details',
},
]}
/>
);
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
items | ArticleRailItem[] | Required | Articles with unique id, title, category, a real href, and decorative ReactNode media. Optional readTime and date strings appear in the metadata. An empty array renders nothing. |
items[].media | ReactNode | Required | Decorative article artwork, rendered inert and hidden from assistive technology. Keep meaningful information in the title and metadata, and do not include nested controls. Supply your own images for the example paths. |
items[].href | string | Required | The article destination. Cards are ordinary anchors, so visitors can open them in a new tab or use your framework’s link adapter if you customize the source. |
items[].category | string | Required | A short editorial category, such as Perspective, Process, or News. |
items[].readTime / date | string | undefined | Optional preformatted metadata. Supply localized strings such as “4 min read” or “25 October 2026”; the component does not infer reading time or parse dates. |
title | string | 'Ideas worth sharing' | The visible section heading and accessible section label. |
className | string | '' | Additional classes. Set --ac-article-surface, --ac-article-ink, --ac-article-muted, --ac-article-line, and --ac-article-accent on this element to match your website. |
Motion & accessibility
Swipe, use a trackpad, or activate Previous/Next to browse one card at a time. CSS scroll snapping settles the rail on a card. Controls disable at their respective edges and update after scrolling or resizing. Each article stays an ordinary focusable link; Tab brings off-screen links into view. There is no autoplay. Reduced motion makes button-driven paging immediate and removes image, arrow, and underline transitions. The demo opens short local sample articles; replace those destinations with your own journal pages.
Use semantic content and meaningful labels in your composition. Check contrast and keyboard behavior with your own styles and content.
Inspiration
The image-led ideas and news cards on Whipsaw’s homepage informed the hierarchy of category, reading time, and article title. Anticraft’s scroll rail, paging controls, and artwork are an original adaptation for homepage journals and resource sections.
Visit Whipsaw — Ideas and newsThe component source and demo artwork are original to Anticraft UI. The linked site is the design or content reference.