Skip to content
ilinxa/pro-ui

Magazine Layout

alphav0.3.0

Slot-based magazine layout — hero, filter bar, sidebar, and a mixed-size article grid with infinite scroll and a filter hook.

Category: LayoutUpdated: 2026-08-11Created: 2026-05-02Author: ilinxa

Context

The layout assembly for the news-domain family. Generic over `T`; renders cards via a `renderItem(item, slot)` callback so the layout itself imports nothing from sibling registry components — composition happens at the consumer level. Pair with news-card (renderItem), filter-bar (filterBar slot), category-cloud + newsletter-signup (sidebar slot), and page-hero (hero slot) for the full kasder magazine experience. The companion `useMagazineFilter` hook gives the simple consumer one-line filter+page state; sophisticated consumers skip the hook and drive props from React Query / their router. Migration origin: kasder kas-social-front-v0 NewsMagazineGrid.tsx (~320-line component) → distilled to this slot-based shell + a 60-line filter hook. The original's filter logic / chip row / search / date picker / sidebar contents all moved into the dedicated sibling components.

Installation

Initialize shadcn (once per project)Seeds lib/utils.ts and components.json. Skip if you've already used any shadcn component.
pnpm dlx shadcn@latest init
Install the component
pnpm dlx shadcn@latest add @ilinxa/magazine-layout

Add -fixtures for dummy data:

pnpm dlx shadcn@latest add @ilinxa/magazine-layout-fixtures

CLI can't resolve @ilinxa? The namespace is listed in the official shadcn registry directory, so current CLIs need no configuration. If yours can't resolve it (older or pinned versions, self-hosted mirrors), register it manually in components.json:

"registries": {
  "@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}

Preview

Urban

Featured: Editorial team announces 2026 redesign

A short summary of the article — used to give the reader a taste of the content before they click through to the full piece.

Sustainability

Article 2: Urban planning trends shaping the year ahead

A short summary of the article — used to give the reader a taste of the content before they click through to the full piece.

Aug 19, 2026

Tech

Article 3: Sustainability moves to center stage

A short summary of the article — used to give the reader a taste of the content before they click through to the full piece.

Aug 18, 2026

Events

Article 4: Smart city tech rolls out across metros

A short summary of the article — used to give the reader a taste of the content before they click through to the full piece.

Aug 17, 2026

Research

Article 5: Public transit goes electric

A short summary of the article — used to give the reader a taste of the content before they click through to the full piece.

Aug 16, 2026

Urban

Article 6: Permitting goes digital, finally

A short summary of the article — used to give the reader a taste of the content before they click through to the full piece.

Aug 15, 2026

Sustainability

Article 7: Award-winning planners share their process

A short summary of the article — used to give the reader a taste of the content before they click through to the full piece.

Aug 14, 2026

Demo source

demo.tsxtsx
"use client"; import { useState } from "react";import { Badge } from "@/components/ui/badge";import { Button } from "@/components/ui/button";import { Input } from "@/components/ui/input";import { Tabs, TabsContent, TabsTrigger } from "@/components/ui/tabs";import { SwipeTabsList } from "@/components/site/swipe-tabs-list";import { MagazineLayout } from "./magazine-layout";import { useMagazineFilter } from "./hooks/use-magazine-filter";import { DEMO_ARTICLES, type DemoArticle } from "./dummy-data";import type { MagazineLayoutItemSlot } from "./types"; /** * Demo cards inlined here (rather than importing sibling registry components) * to keep the registry's sealed-folder convention. The cards mimic the * visual rhythm consumers would compose using news-card in real * applications. */function DemoCard({  article,  slot,}: {  article: DemoArticle;  slot: MagazineLayoutItemSlot;}) {  const isLarge = slot === "large";  return (    <article      className={`group flex h-full flex-col overflow-hidden rounded-2xl border border-border/50 bg-card transition-all duration-300 hover:shadow-xl ${        isLarge ? "md:flex-row" : ""      }`}    >      <div        className={`relative bg-linear-to-br from-primary/40 to-accent/40 ${          isLarge ? "h-48 md:h-auto md:w-1/2" : "h-48"        }`}      >        <div className="absolute left-4 top-4">          <Badge variant="secondary">{article.category}</Badge>        </div>      </div>      <div className={`flex flex-1 flex-col p-6 ${isLarge ? "md:p-8" : ""}`}>        <h3          className={`mb-2 font-bold transition-colors line-clamp-2 group-hover:text-primary ${            isLarge ? "text-2xl md:text-3xl" : "text-xl"          }`}        >          {article.title}        </h3>        <p className="mb-4 flex-1 text-sm text-muted-foreground line-clamp-3">          {article.excerpt}        </p>        <p className="mt-auto pt-4 border-t border-border/50 text-xs text-muted-foreground">          {article.date}        </p>      </div>    </article>  );} function FeaturedCard({ article }: { article: DemoArticle }) {  return (    <article className="group relative h-80 overflow-hidden rounded-2xl bg-linear-to-br from-primary/60 via-primary/40 to-accent/40 md:h-96">      <div className="absolute inset-0 bg-linear-to-t from-black via-black/50 to-transparent" />      <div className="absolute inset-0 flex flex-col justify-end p-8 md:p-12">        <Badge className="mb-4 w-fit bg-black/40 text-white backdrop-blur-sm">          {article.category}        </Badge>        <h2 className="mb-4 text-3xl font-bold leading-tight text-white md:text-4xl lg:text-5xl">          {article.title}        </h2>        <p className="max-w-3xl text-lg text-white/80 line-clamp-2">          {article.excerpt}        </p>      </div>    </article>  );} function DemoFilterBar({ onSearch }: { onSearch: (s: string) => void }) {  return (    <div className="flex flex-col items-center gap-4">      <div className="relative w-full max-w-xl">        <Input          placeholder="Search articles…"          onChange={(e) => onSearch(e.target.value.toLowerCase())}          className="h-12 rounded-xl"        />      </div>    </div>  );} function DemoSidebar() {  return (    <>      <div className="rounded-2xl border border-border/50 bg-card p-6">        <h3 className="mb-4 border-b border-border pb-2 font-bold text-foreground">          Categories        </h3>        <div className="flex flex-wrap gap-2">          {["Urban", "Sustainability", "Tech", "Events", "Research"].map((c) => (            <Badge key={c} variant="secondary" className="cursor-pointer">              {c}            </Badge>          ))}        </div>      </div>      <div className="rounded-2xl border border-primary/20 bg-primary/5 p-6">        <h3 className="mb-2 font-bold text-foreground">Join our newsletter</h3>        <p className="mb-4 text-sm text-muted-foreground">          Latest stories, weekly.        </p>        <Button className="w-full">Subscribe</Button>      </div>    </>  );} export default function MagazineLayoutDemo() {  const [search, setSearch] = useState("");   const filtered = useMagazineFilter<DemoArticle>({    items: DEMO_ARTICLES,    pageSize: 6,    isFeatured: (a) => Boolean(a.featured),    filterPredicate: search      ? (a) => a.title.toLowerCase().includes(search)      : undefined,    simulatedLoadingMs: 500,  });   return (    <Tabs defaultValue="composed" className="w-full">      <SwipeTabsList>        <TabsTrigger value="composed">Slot composition</TabsTrigger>        <TabsTrigger value="bare">Bare layout</TabsTrigger>        <TabsTrigger value="empty">Empty state</TabsTrigger>      </SwipeTabsList>       <TabsContent value="composed" className="mt-6">        <MagazineLayout<DemoArticle>          displayedItems={filtered.displayedItems}          featuredItem={filtered.featuredItem}          hasMore={filtered.hasMore}          isLoading={filtered.isLoading}          onLoadMore={filtered.loadMore}          renderItem={({ item: article, slot }) => (            <DemoCard article={article} slot={slot} />          )}          renderFeatured={(article) => <FeaturedCard article={article} />}          filterBar={<DemoFilterBar onSearch={setSearch} />}          sidebar={<DemoSidebar />}        />      </TabsContent>       <TabsContent value="bare" className="mt-6">        <MagazineLayout<DemoArticle>          displayedItems={DEMO_ARTICLES.slice(0, 6)}          renderItem={({ item: article, slot }) => (            <DemoCard article={article} slot={slot} />          )}        />      </TabsContent>       <TabsContent value="empty" className="mt-6">        <MagazineLayout<DemoArticle>          displayedItems={[]}          renderItem={({ item: article, slot }) => (            <DemoCard article={article} slot={slot} />          )}          emptyState={            <div className="space-y-2">              <p className="text-lg font-semibold text-foreground">                No articles yet              </p>              <p className="text-sm text-muted-foreground">                Check back soon — we publish weekly.              </p>            </div>          }        />      </TabsContent>    </Tabs>  );} 

Usage

When to use

Reach for MagazineLayout when you need a magazine- style layout: optional hero band, optional filter row, descending- density card tower in a main column, optional sticky sidebar, infinite scroll. The layout is generic over your item type and slot-based — bring your own cards, hero, filter bar, sidebar.

Minimal example

import { MagazineLayout } from "@/components/magazine-layout";

<MagazineLayout<Article>
  displayedItems={articles}
  renderItem={(article, slot) => (
    <ArticleCard article={article} variant={slot} />
  )}
/>;

slot is either "large" (lead article) or "medium" (the rest of the tower). Your renderer maps slot to a card variant.

Full magazine composition (the canonical use case)

Combines all four sibling components from the news family. The layout itself imports nothing from them — composition happens at the consumer level.

import { MagazineLayout, useMagazineFilter } from "@/components/magazine-layout";
import { NewsCard } from "@/components/news-card";
import { FilterBar } from "@/components/filter-bar";
import { CategoryCloud } from "@/components/category-cloud";
import { NewsletterSignup } from "@/components/newsletter-signup";
import { PageHero } from "@/components/page-hero";

const filtered = useMagazineFilter<Article>({
  items: articles,
  pageSize: 6,
  isFeatured: (a) => a.featured,
  filterPredicate: (a) => a.title.includes(search) && (cat ? a.category === cat : true),
  sortComparator: (a, b) => +new Date(b.date) - +new Date(a.date),
});

<MagazineLayout<Article>
  hero={<PageHero badge="News" title="Latest Stories" description="..." />}
  filterBar={<FilterBar categories={categories} onChange={...} />}
  sidebar={
    <>
      <CategoryCloud items={categories} value={cat} onChange={setCat} title="Categories" />
      <NewsletterSignup onSubmit={subscribe} />
    </>
  }
  displayedItems={filtered.displayedItems}
  featuredItem={filtered.featuredItem}
  hasMore={filtered.hasMore}
  isLoading={filtered.isLoading}
  onLoadMore={filtered.loadMore}
  renderItem={(article, slot) => (
    <NewsCard item={article} variant={slot} href={`/news/${article.id}`} />
  )}
/>;

Companion hook: useMagazineFilter

For consumers who don't need server-driven filtering / pagination, useMagazineFilter derives all the props the layout expects from your full items array + filter / sort / feature predicates.

const {
  displayedItems,
  featuredItem,
  hasMore,
  isLoading,
  filteredCount,
  loadMore,
  reset,
} = useMagazineFilter<Article>({
  items,
  pageSize: 6,
  isFeatured: (a) => a.featured,
  filterPredicate: (a) => /* filter */,
  sortComparator: (a, b) => /* sort */,
  simulatedLoadingMs: 500, // optional artificial delay for demos
});

Slot-only patterns

  • Omit sidebar — main column expands to full width.
  • Omit hero and filterBar — bare layout for documentation indexes or simple lists.
  • Provide emptyState for a custom empty fallback; otherwise the default labels.emptyStateText renders.
  • Use renderFeatured for a different visual on the featured item (e.g., a hero card variant) vs. the in-tower large.

Server-driven filtering / pagination

Skip the companion hook. Drive displayedItems / hasMore / isLoading / onLoadMore from your data layer (React Query, Tanstack Router, etc.) directly:

const { data, fetchNextPage, hasNextPage, isFetching } = useInfiniteQuery(...);

<MagazineLayout
  displayedItems={data?.pages.flatMap(p => p.items) ?? []}
  hasMore={!!hasNextPage}
  isLoading={isFetching}
  onLoadMore={() => fetchNextPage()}
  renderItem={...}
/>;

Accessibility

  • Sidebar uses <aside> for landmark semantics.
  • Loader region has aria-live="polite" with a visually-hidden text label.
  • End-of-list announcement also uses aria-live="polite".
  • Tower is plain CSS grid — natural Tab order through items.

Features

  • Slot-based layout — `hero` / `filterBar` / `sidebar` / `renderItem` / `renderFeatured` / `emptyState`
  • Generic over item type via `<MagazineLayout<T>>`
  • Magazine-tower main column — 1 large + 2-up medium row + 3-up medium grid
  • Persistent featured slot via `featuredItem` prop — the companion hook keeps it out of the paged regular list, so it renders once and survives load-more
  • Sticky sidebar (`sticky top-24`) with `<aside>` landmark
  • IntersectionObserver-driven infinite scroll via internal `useInfiniteScroll` hook
  • Bouncing-3-dots loader with `aria-live='polite'` and visually-hidden text label
  • End-of-list announcement also `aria-live='polite'`
  • Empty state slot or `labels.emptyStateText` fallback
  • Companion `useMagazineFilter` hook — in-memory filter + sort + page + simulated loading
  • Sealed-folder convention — layout imports zero sibling registry components at runtime

Tags

magazine-layoutlayoutmagazinefeedinfinite-scrollmigrationnews

Dependencies