Blog Post Card Grid
blockmediaModern editorial magazine 3-column article card grid with featured hero story, reading time pills, category filter badges, author avatars, and newsletter subscription box.
Installation
$pnpm dlx uipkge-ng@latest add blog-post-card-grid$npx uipkge-ng@latest add blog-post-card-grid$yarn dlx uipkge-ng@latest add blog-post-card-grid$bunx uipkge-ng@latest add blog-post-card-gridManifest:
https://uipkge.dev/r/angular/blog-post-card-grid.jsonInstalls to:src/app/components/blocks/blog-post-card-grid/Variants
Loading interactive previews…
Loading interactive previews…
Schema
Type aliases exported from this item's source. Use these to shape the data you pass in.
Authorinterface Author {
name: string
role: string
avatar: string
initials: string
}BlogPostinterface BlogPost {
id: string
title: string
excerpt: string
category: 'Architecture' | 'Design Systems' | 'Performance' | 'Tutorials'
readTime: string
date: string
image: string
author: Author
}Files installed (2)
src/app/components/blocks/blog-post-card-grid/blog-post-card-grid.component.ts22.6 kB
// Modern editorial magazine 3-column article card grid with featured hero story, reading // time pills, category filter pills, author avatars, and a newsletter subscription box. // Port of the React block 1:1. import { ChangeDetectionStrategy, Component, Input, computed, signal } from '@angular/core' import { ArrowRight, BookOpen, Check, Clock, Loader2, LucideAngularModule, Mail, Star } from 'lucide-angular' import { cn } from '@/lib/utils' import { UiAvatarFallbackComponent, UiAvatarImageComponent, UiAvatarComponent } from '@/ui/avatar/avatar.component' import { UiBadgeComponent } from '@/ui/badge/badge.component' import { UiButtonComponent } from '@/ui/button/button.component' import { UiCardComponent, UiCardContentComponent } from '@/ui/card/card.component' import { UiInputComponent } from '@/ui/input/input.component' import { UiSeparatorComponent } from '@/ui/separator/separator.component' export interface Author { name: string role: string avatar: string initials: string } export interface BlogPost { id: string title: string excerpt: string category: 'Architecture' | 'Design Systems' | 'Performance' | 'Tutorials' readTime: string date: string image: string author: Author } const categories = ['All', 'Architecture', 'Design Systems', 'Performance', 'Tutorials'] as const export type BlogCategory = (typeof categories)[number] const featuredPost: BlogPost = { id: 'featured-1', title: 'Deconstructing Event-Driven Micro-Frontends at Scale', excerpt: 'How we transitioned a monolithic dashboard into decoupled, independently deployable island architectures using web components, resilient event buses, and broadcast channels.', category: 'Architecture', readTime: '8 min read', date: 'Aug 18, 2026', image: 'https://images.unsplash.com/photo-1558494949-ef010cbdcc31?w=1200&auto=format&fit=crop&q=80', author: { name: 'Elena Rostova', role: 'Principal Systems Architect', avatar: 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=150&auto=format&fit=crop&q=80', initials: 'ER', }, } const initialPosts: BlogPost[] = [ { id: 'post-1', title: 'Architecting Token Hierarchies in Tailwind CSS v4', excerpt: 'A comprehensive guide to structured OKLCH color palettes, dynamic light and dark elevation layers, and type scale ergonomics.', category: 'Design Systems', readTime: '5 min read', date: 'Aug 16, 2026', image: 'https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?w=800&auto=format&fit=crop&q=80', author: { name: 'Marcus Chen', role: 'Design Technologist', avatar: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=150&auto=format&fit=crop&q=80', initials: 'MC', }, }, { id: 'post-2', title: 'Eliminating Layout Shift in Hydrated Islands', excerpt: 'Techniques for zero-CLS component streaming, skeleton synchronization, and sub-100ms Interaction to Next Paint (INP).', category: 'Performance', readTime: '6 min read', date: 'Aug 14, 2026', image: 'https://images.unsplash.com/photo-1551288049-bebda4e38f71?w=800&auto=format&fit=crop&q=80', author: { name: 'Sarah Jenkins', role: 'Performance Engineer', avatar: 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=150&auto=format&fit=crop&q=80', initials: 'SJ', }, }, { id: 'post-3', title: 'Deterministic State Synchronization Over WebSockets', excerpt: 'Building conflict-free collaborative data models with operational transformation and CRDTs for multi-tenant SaaS.', category: 'Architecture', readTime: '7 min read', date: 'Aug 11, 2026', image: 'https://images.unsplash.com/photo-1526374965328-7f61d4dc18c5?w=800&auto=format&fit=crop&q=80', author: { name: 'Devon Vance', role: 'Distributed Systems Lead', avatar: 'https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=150&auto=format&fit=crop&q=80', initials: 'DV', }, }, { id: 'post-4', title: 'Building Headless Accessible Dropdowns with Reka UI', excerpt: 'Step-by-step implementation of focus trap management, keyboard navigation patterns, and polymorphic slot composition.', category: 'Tutorials', readTime: '4 min read', date: 'Aug 08, 2026', image: 'https://images.unsplash.com/photo-1555066931-4365d14bab8c?w=800&auto=format&fit=crop&q=80', author: { name: 'Aaliyah Patel', role: 'Frontend Engineer', avatar: 'https://images.unsplash.com/photo-1573496359142-b8d87734a5a2?w=150&auto=format&fit=crop&q=80', initials: 'AP', }, }, { id: 'post-5', title: 'Fluid Typography and Motion Curves in Modern Web Apps', excerpt: 'Using CSS clamp functions and spring physics to create tactile, responsive interfaces that adapt directly across devices.', category: 'Design Systems', readTime: '5 min read', date: 'Aug 04, 2026', image: 'https://images.unsplash.com/photo-1507238691740-187a5b1d37b8?w=800&auto=format&fit=crop&q=80', author: { name: 'Lucas Meyer', role: 'UI Engineer', avatar: 'https://images.unsplash.com/photo-1519085360753-af0119f7cbe7?w=150&auto=format&fit=crop&q=80', initials: 'LM', }, }, { id: 'post-6', title: 'Continuous Benchmarking with Playwright and GitHub Actions', excerpt: 'Automate regression detection for memory leaks, bundle sizes, and render latency directly within your CI pull request checks.', category: 'Tutorials', readTime: '6 min read', date: 'Jul 30, 2026', image: 'https://images.unsplash.com/photo-1460925895917-afdab827c52f?w=800&auto=format&fit=crop&q=80', author: { name: 'Elena Rostova', role: 'Principal Systems Architect', avatar: 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=150&auto=format&fit=crop&q=80', initials: 'ER', }, }, ] const additionalPosts: BlogPost[] = [ { id: 'post-7', title: 'Optimizing JavaScript Garbage Collection in High-Frequency Canvas', excerpt: 'How object pooling and typed array buffers solved stuttering frame rates during real-time telemetry rendering.', category: 'Performance', readTime: '9 min read', date: 'Jul 26, 2026', image: 'https://images.unsplash.com/photo-1504639725590-34d0984388bd?w=800&auto=format&fit=crop&q=80', author: { name: 'Marcus Chen', role: 'Design Technologist', avatar: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=150&auto=format&fit=crop&q=80', initials: 'MC', }, }, { id: 'post-8', title: 'Resilient Edge Caching Strategies for Dynamic Multi-Region APIs', excerpt: 'Configuring stale-while-revalidate headers, cache tagging, and instant surrogate key purging across worldwide POPs.', category: 'Architecture', readTime: '7 min read', date: 'Jul 22, 2026', image: 'https://images.unsplash.com/photo-1518770660439-4636190af475?w=800&auto=format&fit=crop&q=80', author: { name: 'Devon Vance', role: 'Distributed Systems Lead', avatar: 'https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=150&auto=format&fit=crop&q=80', initials: 'DV', }, }, { id: 'post-9', title: 'Designing High-Contrast Dark Modes without Washing Out Brand Identity', excerpt: 'Tuning APCA perceptual contrast ratios, luminance curves, and colored alpha borders in enterprise dark themes.', category: 'Design Systems', readTime: '5 min read', date: 'Jul 18, 2026', image: 'https://images.unsplash.com/photo-1550751827-4bd374c3f58b?w=800&auto=format&fit=crop&q=80', author: { name: 'Aaliyah Patel', role: 'Frontend Engineer', avatar: 'https://images.unsplash.com/photo-1573496359142-b8d87734a5a2?w=150&auto=format&fit=crop&q=80', initials: 'AP', }, }, ] @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-blog-post-card-grid, [ui-blog-post-card-grid]', standalone: true, // React renders the root <section> itself: the host stays out of layout and `class` goes to the root. host: { '[attr.class]': '"contents"' }, imports: [ LucideAngularModule, UiAvatarComponent, UiAvatarFallbackComponent, UiAvatarImageComponent, UiBadgeComponent, UiButtonComponent, UiCardComponent, UiCardContentComponent, UiInputComponent, UiSeparatorComponent, ], template: ` <section data-slot="blog-post-card-grid" [class]="rootClass"> <div class="mx-auto max-w-7xl space-y-12 px-4 sm:space-y-16 sm:px-6 lg:px-8"> <!-- Section Header --> <div class="flex flex-col items-start justify-between gap-6 md:flex-row md:items-end"> <div class="max-w-2xl space-y-3"> <span ui-badge variant="outline" class="gap-1.5 px-3 py-1 text-xs font-medium"> <lucide-icon [img]="BookOpen" class="text-primary size-3.5" /> From the Blog </span> <h2 class="text-foreground text-3xl font-bold tracking-tight sm:text-4xl lg:text-5xl"> Latest Articles & Engineering Deep Dives </h2> <p class="text-muted-foreground text-base leading-relaxed sm:text-lg"> Technical essays on software architecture, design systems, and frontend performance. </p> </div> <!-- Category Filter Pills --> <div class="flex flex-wrap items-center gap-2" role="tablist" aria-label="Article categories"> @for (cat of categories; track cat) { <button ui-button size="sm" [variant]="activeCategory() === cat ? 'default' : 'outline'" class="rounded-full text-xs font-medium" role="tab" [attr.aria-selected]="activeCategory() === cat" (click)="handleCategorySelect(cat)" > {{ cat }} </button> } </div> </div> <!-- Featured Hero Article Card --> @if (isHeroVisible()) { <div ui-card class="group/hero border-border bg-card hover:border-primary/30 relative overflow-hidden shadow-xs transition-colors duration-300 hover:shadow-md" > <div class="grid grid-cols-1 lg:grid-cols-12"> <div class="bg-muted relative aspect-video min-h-[260px] min-w-0 overflow-hidden sm:min-h-[340px] lg:col-span-7 lg:aspect-auto" > <img [src]="featured.image" [alt]="featured.title" class="size-full object-cover transition-transform duration-500 ease-out group-hover/hero:scale-105" /> <div class="absolute top-4 left-4 flex flex-wrap items-center gap-2"> <span ui-badge class="bg-primary text-primary-foreground border-transparent text-xs font-medium shadow-xs" > <lucide-icon [img]="Star" class="mr-1 size-3" /> Featured Story </span> <span ui-badge variant="secondary" class="bg-background/90 text-foreground border-border/60 text-xs font-medium shadow-xs backdrop-blur-md" > {{ featured.category }} </span> </div> </div> <div ui-card-content class="flex flex-col justify-between space-y-6 p-6 sm:p-8 lg:col-span-5 lg:p-10"> <div class="space-y-4"> <div class="text-muted-foreground flex items-center gap-2 text-xs font-medium"> <lucide-icon [img]="BookOpen" class="text-primary size-3.5" /> <span>Editorial Dispatch</span> <span>•</span> <span class="inline-flex items-center gap-1"> <lucide-icon [img]="Clock" class="size-3" /> {{ featured.readTime }} </span> </div> <h3 class="text-foreground group-hover/hero:text-primary text-2xl leading-tight font-bold tracking-tight transition-colors sm:text-3xl" > <a href="#" class="focus-visible:ring-ring focus-visible:underline focus-visible:outline-none"> {{ featured.title }} </a> </h3> <p class="text-muted-foreground line-clamp-4 text-sm leading-relaxed sm:text-base"> {{ featured.excerpt }} </p> </div> <div class="space-y-5 pt-2"> <ui-separator class="bg-border/60" /> <div class="flex flex-wrap items-center justify-between gap-4"> <div class="flex items-center gap-3"> <ui-avatar size="default" class="border-border/60 border shadow-xs"> <ui-avatar-image [src]="featured.author.avatar" [alt]="featured.author.name" /> <ui-avatar-fallback [text]="featured.author.initials" /> </ui-avatar> <div> <p class="text-foreground text-sm font-semibold">{{ featured.author.name }}</p> <p class="text-muted-foreground text-xs"> {{ featured.date }} · {{ featured.author.role }} </p> </div> </div> <button ui-button size="sm" class="group/btn gap-1.5"> <span>Read Article</span> <lucide-icon [img]="ArrowRight" class="size-3.5 transition-transform duration-200 group-hover/btn:translate-x-1" /> </button> </div> </div> </div> </div> </div> } <!-- 3-Column Article Card Grid --> @if (visiblePosts().length > 0) { <div class="grid grid-cols-1 gap-6 sm:gap-8 md:grid-cols-2 lg:grid-cols-3"> @for (post of visiblePosts(); track post.id) { <div ui-card class="group border-border bg-card hover:border-primary/30 flex flex-col justify-between overflow-hidden shadow-xs transition-colors duration-300 hover:shadow-md" > <div class="bg-muted relative aspect-video w-full overflow-hidden"> <img [src]="post.image" [alt]="post.title" loading="lazy" class="size-full object-cover transition-transform duration-500 ease-out group-hover:scale-105" /> <div class="absolute top-3 left-3"> <span ui-badge variant="secondary" class="bg-background/85 text-foreground border-border/50 text-xs font-medium shadow-xs backdrop-blur-md" > {{ post.category }} </span> </div> </div> <div class="flex flex-1 flex-col justify-between space-y-4 p-5 sm:p-6"> <div class="space-y-2.5"> <h3 class="text-foreground group-hover:text-primary line-clamp-2 text-lg font-semibold tracking-tight transition-colors" > <a href="#" class="focus-visible:ring-ring focus-visible:underline focus-visible:outline-none"> {{ post.title }} </a> </h3> <p class="text-muted-foreground line-clamp-2 text-sm leading-relaxed"> {{ post.excerpt }} </p> </div> <div class="space-y-4 pt-2"> <ui-separator class="bg-border/60" /> <div class="flex items-center justify-between gap-3"> <div class="flex min-w-0 items-center gap-2.5"> <ui-avatar size="sm" class="border-border/60 border"> <ui-avatar-image [src]="post.author.avatar" [alt]="post.author.name" /> <ui-avatar-fallback [text]="post.author.initials" /> </ui-avatar> <div class="min-w-0 flex-1"> <p class="text-foreground truncate text-xs font-medium">{{ post.author.name }}</p> <p class="text-muted-foreground truncate text-xs">{{ post.date }}</p> </div> </div> <div class="text-muted-foreground bg-muted/60 flex shrink-0 items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium" > <lucide-icon [img]="Clock" class="size-3" /> <span>{{ post.readTime }}</span> </div> </div> </div> </div> </div> } </div> } @else { <!-- Empty State When No Posts Match Category --> <div class="border-border bg-card/40 space-y-3 rounded-xl border border-dashed p-10 text-center"> <div class="bg-muted text-muted-foreground mx-auto flex size-12 items-center justify-center rounded-full"> <lucide-icon [img]="BookOpen" class="size-6" /> </div> <h3 class="text-foreground text-base font-semibold">No articles found</h3> <p class="text-muted-foreground text-sm"> There are no articles published yet under “{{ activeCategory() }}”. </p> <button ui-button variant="outline" size="sm" (click)="activeCategory.set('All')">Show all articles</button> </div> } <!-- Load More Stories Button --> @if (!showAllPosts() && visiblePosts().length > 0) { <div class="flex justify-center pt-2"> <button ui-button variant="outline" size="lg" class="gap-2 px-8 font-medium shadow-xs" [disabled]="isLoadingMore()" (click)="handleLoadMore()" > @if (isLoadingMore()) { <lucide-icon [img]="Loader2" class="size-4 animate-spin" /> } <span>{{ isLoadingMore() ? 'Loading articles...' : 'Load More Stories' }}</span> </button> </div> } <!-- Newsletter Subscribe Box --> <div ui-card class="border-border bg-card/60 relative overflow-hidden shadow-xs"> <div ui-card-content class="relative z-10 flex flex-col items-center justify-between gap-8 p-8 sm:p-10 md:flex-row" > <div class="max-w-xl space-y-2 text-center md:text-left"> <div class="bg-primary/10 text-primary mx-auto flex size-10 items-center justify-center rounded-lg md:mx-0" > <lucide-icon [img]="Mail" class="size-5" /> </div> <h3 class="text-foreground text-2xl font-bold tracking-tight">Subscribe to our editorial dispatch</h3> <p class="text-muted-foreground text-sm leading-relaxed"> Get weekly teardowns on frontend architecture, component design systems, and web performance delivered straight to your inbox. </p> </div> <div class="w-full max-w-md"> @if (!isSubscribed()) { <form class="flex flex-col gap-2 sm:flex-row" (submit)="handleSubscribe($event)"> <ui-input [value]="emailInput()" (valueChange)="emailInput.set($event)" type="email" placeholder="name@company.com" required aria-label="Email address" class="bg-background h-10 shadow-xs" /> <button ui-button type="submit" class="shrink-0">Subscribe</button> </form> <p class="text-muted-foreground mt-2 text-center text-xs sm:text-left"> No spam. Sent bi-weekly. Unsubscribe with one click. </p> } @else { <div class="bg-primary/10 border-primary/20 text-foreground flex items-center gap-3 rounded-lg border p-3.5 text-sm font-medium" > <lucide-icon [img]="Check" class="text-primary size-5 shrink-0" /> <span>You're subscribed! Check your inbox for the welcome issue.</span> </div> } </div> </div> </div> </div> </section> `, }) export class UiBlogPostCardGridComponent { protected readonly ArrowRight = ArrowRight protected readonly BookOpen = BookOpen protected readonly Check = Check protected readonly Clock = Clock protected readonly Loader2 = Loader2 protected readonly Mail = Mail protected readonly Star = Star protected readonly categories = categories protected readonly featured = featuredPost @Input() showFeatured = true @Input('class') className?: string readonly activeCategory = signal<BlogCategory>('All') readonly isLoadingMore = signal(false) readonly showAllPosts = signal(false) readonly emailInput = signal('') readonly isSubscribed = signal(false) private loadTimer: ReturnType<typeof setTimeout> | undefined readonly visiblePosts = computed(() => { const pool = this.showAllPosts() ? [...initialPosts, ...additionalPosts] : initialPosts if (this.activeCategory() === 'All') return pool return pool.filter((p) => p.category === this.activeCategory()) }) readonly isHeroVisible = computed(() => { if (!this.showFeatured) return false return this.activeCategory() === 'All' || featuredPost.category === this.activeCategory() }) get rootClass(): string { return cn('bg-background w-full py-12 sm:py-16 lg:py-20', this.className) } handleCategorySelect(cat: BlogCategory): void { this.activeCategory.set(cat) } handleLoadMore(): void { this.isLoadingMore.set(true) clearTimeout(this.loadTimer) this.loadTimer = setTimeout(() => { this.showAllPosts.set(true) this.isLoadingMore.set(false) }, 400) } handleSubscribe(event: Event): void { event.preventDefault() if (!this.emailInput().includes('@')) return this.isSubscribed.set(true) } }src/app/components/blocks/blog-post-card-grid/index.ts0.1 kB
export { UiBlogPostCardGridComponent, type Author, type BlogCategory, type BlogPost, } from './blog-post-card-grid.component'
Raw manifest:https://uipkge.dev/r/angular/blog-post-card-grid.json