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.
Slot-based magazine layout — hero, filter bar, sidebar, and a mixed-size article grid with infinite scroll and a filter hook.
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.
pnpm dlx shadcn@latest initpnpm dlx shadcn@latest add @ilinxa/magazine-layoutAdd -fixtures for dummy data:
pnpm dlx shadcn@latest add @ilinxa/magazine-layout-fixturesCLI 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"
}A short summary of the article — used to give the reader a taste of the content before they click through to the full piece.
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
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
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
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
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
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
"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> );} 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.
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.
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}`} />
)}
/>;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
});sidebar — main column expands to full width.hero and filterBar — bare layout for documentation indexes or simple lists.emptyState for a custom empty fallback; otherwise the default labels.emptyStateText renders.renderFeatured for a different visual on the featured item (e.g., a hero card variant) vs. the in-tower large.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={...}
/>;<aside> for landmark semantics.aria-live="polite" with a visually-hidden text label.aria-live="polite".