uiable
blockblock-blocks-showcasesource: ok · hit
Blocks Showcase
Blocks Showcase variant for block.
npx shadcn add "https://uiable.com/r/block-blocks-showcase.json"free
No selection tags yet — empty means no evidence, not lack of support.
Dependencies
Bundle weight · light@tabler/icons-react
Registry dependencies
@uiable/badge
Files7 · source cached 10/8/2026, 7:41:23 PM
src/components/uiable/blocks/landing/blocks-showcase/blocks-showcase.tsx
// next
import Link from "next/link"
// shadcn
import { Badge } from "@/components/ui/badge"
// third-party
import { cn } from "cn"
// project-imports
import categoryCounts from "@/category-counts.json"
import AutoScrollPreview from "@/components/animation/AutoScrollPreview"
import CrossfadeImages from "@/components/animation/CrossfadeImages"
import MarqueeImages from "@/components/animation/MarqueeImages"
import TestimonialsMarquee from "@/components/animation/TestimonialsMarquee"
import SectionHeader from "@/components/uiable/blocks/landing/components/SectionHeader"
// assets
import {
IconArrowUpRight,
IconChartBar,
IconLayoutBoardSplit,
IconLayoutGrid,
IconShoppingCart,
IconUsersGroup,
type Icon as TablerIcon,
} from "@tabler/icons-react"
// ------------------------------ | CONSTANTS | ------------------------------ //
const bentoPreviews = [
{
src: "https://cdn.uiable.com/block/img-bento-1.png",
darkSrc: "https://cdn.uiable.com/block/img-bento-1-dark.png",
},
{
src: "https://cdn.uiable.com/block/img-bento-2.png",
},
{
src: "https://cdn.uiable.com/block/img-bento-3.png",
darkSrc: "https://cdn.uiable.com/block/img-bento-3-dark.png",
},
{
src: "https://cdn.uiable.com/block/img-bento-4.png",
darkSrc: "https://cdn.uiable.com/block/img-bento-4-dark.png",
},
{
src: "https://cdn.uiable.com/block/img-bento-5.png",
darkSrc: "https://cdn.uiable.com/block/img-bento-5-dark.png",
},
{
src: "https://cdn.uiable.com/block/img-bento-6.png",
darkSrc: "https://cdn.uiable.com/block/img-bento-6-dark.png",
},
]
const heroPreviews = [
"https://cdn.uiable.com/block/hero-1.gif",
"https://cdn.uiable.com/block/hero-2.gif",
"https://cdn.uiable.com/block/hero-3.png",
"https://cdn.uiable.com/block/hero-5.png",
"https://cdn.uiable.com/block/hero-4.png",
]
const ecommercePreviews = [
{
src: "https://cdn.uiable.com/block/e-commerce-1-light.png",
darkSrc: "https://cdn.uiable.com/block/e-commerce-1-dark.png",
},
{
src: "https://cdn.uiable.com/block/e-commerce-2-light.png",
darkSrc: "https://cdn.uiable.com/block/e-commerce-2-dark.png",
},
{
src: "https://cdn.uiable.com/block/e-commerce-3-light.png",
darkSrc: "https://cdn.uiable.com/block/e-commerce-3-dark.png",
},
{
src: "https://cdn.uiable.com/block/e-commerce-4-light.png",
darkSrc: "https://cdn.uiable.com/block/e-commerce-4-dark.png",
},
{
src: "https://cdn.uiable.com/block/e-commerce-5-light.png",
darkSrc: "https://cdn.uiable.com/block/e-commerce-5-dark.png",
},
]
const teamPreview = {
src: "https://cdn.uiable.com/block/team-light-1.png",
darkSrc: "https://cdn.uiable.com/block/team-dark-1.png",
}
const testimonialPreview = {
src: "https://cdn.uiable.com/block/testimonial-light-1.png",
darkSrc: "https://cdn.uiable.com/block/testimonial-dark-1.png",
}
type Preview =
| { kind: "static"; src: string; darkSrc?: string }
| { kind: "scroll"; src: string }
| {
kind: "crossfade"
images: (string | { src: string; darkSrc?: string })[]
}
| { kind: "marquee"; images: (string | { src: string; darkSrc?: string })[] }
| {
kind: "vscroll"
images: (string | { src: string; darkSrc?: string })[]
}
interface ShowcaseBlock {
slug: string
title: string
description: string
icon: TablerIcon
className: string
preview: Preview
}
const blocks: ShowcaseBlock[] = [
// ---- Left column: two equal-size cards ----
{
slug: "bento",
title: "Bento grids",
description: "Asymmetric feature mosaics for product pages.",
icon: IconLayoutBoardSplit,
className: "lg:col-start-1 lg:row-start-1 lg:row-span-3",
// Animated: screenshots cross-fade on a gentle loop.
preview: { kind: "crossfade", images: bentoPreviews },
},
{
slug: "hero",
title: "Hero sections",
description:
"Opening bands with headline, supporting copy, and primary actions.",
icon: IconLayoutGrid,
className: "lg:col-start-1 lg:row-start-4 lg:row-span-6",
// Animated: a strip of screenshots scrolls up forever (light/dark pair per screenshot).
preview: { kind: "vscroll", images: heroPreviews },
},
// ---- Right column: three equal-size cards ----
{
slug: "team",
title: "Team blocks",
description: "Team grids and member cards with roles, bios, and socials.",
icon: IconUsersGroup,
className: "lg:col-start-2 lg:row-start-1 lg:row-span-3",
preview: { kind: "static", ...teamPreview },
},
{
slug: "e-commerce",
title: "E-commerce",
description: "Product grids, cards, and cart layouts.",
icon: IconShoppingCart,
className: "lg:col-start-2 lg:row-start-4 lg:row-span-3",
// Animated: a strip of screenshots scrolls left forever.
preview: { kind: "marquee", images: ecommercePreviews },
},
{
slug: "statistics",
title: "Statistics",
description:
"Key metrics, stat cards, and numerical displays to showcase impact.",
icon: IconChartBar,
className: "lg:col-start-2 lg:row-start-7 lg:row-span-3",
preview: { kind: "static", ...testimonialPreview },
},
]
const counts = categoryCounts as Record<string, number>
// ------------------------------ | PREVIEW | ------------------------------ //
function BlockPreview({ preview, title }: { preview: Preview; title: string }) {
const alt = `${title} preview`
switch (preview.kind) {
case "crossfade":
return <CrossfadeImages images={preview.images} alt={alt} />
case "scroll":
return <AutoScrollPreview src={preview.src} alt={alt} className="m-2" />
case "marquee":
return (
<MarqueeImages images={preview.images} alt={alt} className="pb-2" />
)
case "vscroll":
return <TestimonialsMarquee images={preview.images} alt={alt} />
case "static":
return (
<div className="flex h-full min-h-[150px] w-full items-start justify-center bg-white px-3 pb-3 dark:bg-card">
<img
src={preview.src}
alt={alt}
loading="lazy"
className={cn(
"h-auto max-h-full w-auto max-w-full rounded-lg object-contain",
preview.darkSrc && "dark:hidden"
)}
/>
{preview.darkSrc && (
<img
src={preview.darkSrc}
alt={alt}
loading="lazy"
className="hidden h-auto max-h-full w-auto max-w-full rounded-lg object-contain dark:block"
/>
)}
</div>
)
}
}
// ------------------------------ | BLOCKS SHOWCASE | ------------------------------ //
function BlockCard({ block }: { block: ShowcaseBlock }) {
const Icon = block.icon
const count = counts[block.slug] ?? 0
return (
<Link
href={`/blocks/${block.slug}`}
aria-label={`Browse ${block.title} blocks`}
className={cn(
"group relative flex min-h-[180px] flex-col gap-2 overflow-hidden rounded-xl border bg-card transition-transform focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background focus-visible:outline-none",
block.className
)}
>
<div className="flex items-center gap-2 px-3 pt-2.5">
<div className="flex size-7 shrink-0 items-center justify-center rounded-md bg-secondary text-foreground">
<Icon className="size-3.5" aria-hidden="true" />
</div>
<div className="flex flex-1 items-center justify-between gap-2">
<h3 className="text-sm leading-none font-medium text-foreground">
{block.title}
</h3>
<span className="relative flex min-w-5 shrink-0 items-center justify-center">
{count > 0 && (
<Badge
variant="outline"
className="rounded-full px-1.5 py-0 text-[11px] transition-opacity group-hover:opacity-0"
>
{count}
</Badge>
)}
<IconArrowUpRight
className="absolute size-4 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100"
aria-hidden="true"
/>
</span>
</div>
</div>
<div className="mt-auto flex w-full min-w-0 flex-1 flex-col lg:min-h-0">
<BlockPreview preview={block.preview} title={block.title} />
</div>
</Link>
)
}
export default function BlocksShowcase() {
return (
<section className="mx-auto flex w-full flex-col gap-12.5 px-4 py-12.5 sm:px-8">
<SectionHeader
title="More than basic Shadcn blocks"
titleClassName="tracking-tight"
subtitle="Explore beautifully crafted Shadcn blocks for landing pages, dashboards, and more. Just copy, paste, and make them yours."
/>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-2 lg:grid-rows-[repeat(9,7rem)]">
{blocks.map((block) => (
<BlockCard key={block.slug} block={block} />
))}
</div>
</section>
)
}
src/category-counts.json
{
"accordion": 10,
"alert": 46,
"alert-dialog": 10,
"aspect-ratio": 5,
"attachment": 5,
"avatar": 15,
"badge": 37,
"layout": 14,
"breadcrumb": 11,
"bubble": 8,
"button": 49,
"button-group": 15,
"calendar": 12,
"card": 24,
"carousel": 9,
"changelog": 3,
"chart": 10,
"checkbox": 11,
"collapsible": 6,
"combobox": 14,
"command": 9,
"context-menu": 13,
"coupon": 3,
"cursor": 3,
"data-table": 4,
"date-picker": 8,
"dialog": 15,
"drawer": 6,
"dropdown-menu": 25,
"empty": 8,
"field": 12,
"hover-card": 5,
"input": 16,
"input-group": 22,
"input-otp": 11,
"item": 15,
"kbd": 7,
"label": 4,
"list-group": 13,
"marker": 7,
"menubar": 8,
"message": 5,
"message-scroller": 9,
"native-select": 3,
"navigation-menu": 3,
"pagination": 11,
"popover": 8,
"progress": 20,
"questionnaire": 7,
"radio": 1,
"radio-group": 9,
"resizable": 7,
"scroll-area": 5,
"select": 10,
"separator": 8,
"sheet": 5,
"sidebar": 17,
"skeleton": 8,
"slider": 8,
"sonner": 8,
"spinner": 21,
"switch": 11,
"table": 6,
"tabs": 10,
"textarea": 8,
"timeline": 7,
"toggle": 6,
"toggle-group": 8,
"tooltip": 11,
"tree-view": 7,
"typography": 22,
"contact": 24,
"content": 17,
"cta": 12,
"e-commerce": 25,
"faq": 19,
"feature": 42,
"footer": 21,
"gallery": 11,
"hero": 32,
"landing": 14,
"portfolio": 11,
"pricing": 19,
"process": 12,
"small-hero": 7,
"statistics": 13,
"team": 14,
"testimonial": 24,
"bento": 8,
"charts": 8,
"check-mail": 2,
"code-verification": 7,
"coming-soon": 2,
"component-layout": 1,
"dashboard-layout": 3,
"doc-layout": 1,
"error-404": 2,
"error-500": 2,
"forgot-password": 5,
"join-waitlist": 1,
"login": 7,
"navbar": 11,
"reset-password": 1,
"sign-up": 7,
"tables": 1,
"under-construction": 2,
"widgets": 20
}
src/components/animation/AutoScrollPreview.tsx
"use client"
import { useEffect, useRef } from "react"
// project-imports
import { cn } from "@/lib/utils"
// types
interface AutoScrollPreviewProps {
/** Image URL — a tall screenshot that scrolls vertically inside the frame. */
src: string
alt?: string
/** Seconds for one full top-to-bottom-and-back scroll cycle. */
duration?: number
className?: string
}
// ------------------------------ | AUTO SCROLL PREVIEW | ------------------------------ //
// A framed screenshot that slowly pans from top to bottom on a loop, as if a
// visitor were scrolling the page. Runs continuously.
export default function AutoScrollPreview({
src,
alt = "",
duration = 14,
className,
}: AutoScrollPreviewProps) {
const imgRef = useRef<HTMLImageElement>(null)
const animationRef = useRef<Animation | null>(null)
useEffect(() => {
const img = imgRef.current
if (!img) return
const start = () => {
animationRef.current = img.animate(
[
{ transform: "translateY(0%)" },
{ transform: "translateY(calc(-100% + 120px))" },
{ transform: "translateY(0%)" },
],
{
duration: duration * 1000,
iterations: Infinity,
easing: "ease-in-out",
}
)
}
if (img.complete) start()
else img.addEventListener("load", start, { once: true })
return () => animationRef.current?.cancel()
}, [src, duration])
return (
<div
className={cn(
"relative h-full min-h-[110px] w-full overflow-hidden rounded-lg bg-muted/40",
className
)}
>
<img
ref={imgRef}
src={src}
alt={alt}
loading="lazy"
className="absolute inset-x-0 top-0 w-full will-change-transform"
/>
{/* Soft fade at top and bottom so the pan reads as a window into a page. */}
<div className="pointer-events-none absolute inset-0 bg-gradient-to-b from-card/40 via-transparent to-card/40" />
</div>
)
}
src/components/animation/CrossfadeImages.tsx
"use client"
import { useEffect, useState } from "react"
// project-imports
import { cn } from "@/lib/utils"
// types
interface CrossfadeImage {
src: string
darkSrc?: string
}
interface CrossfadeImagesProps {
/** Three to five screenshot URLs, shown one at a time on a gentle loop. */
images: (string | CrossfadeImage)[]
alt?: string
/** Milliseconds each screenshot stays on screen before the next fades in. */
interval?: number
className?: string
}
// ------------------------------ | CROSSFADE IMAGES | ------------------------------ //
// A calm slideshow: framed screenshots cross-fade into one another while drifting
// in with a slow zoom. No rotation or bounce — it reads as a quiet product tour.
export default function CrossfadeImages({
images,
alt = "",
interval = 3200,
className,
}: CrossfadeImagesProps) {
const [active, setActive] = useState(0)
useEffect(() => {
if (images.length <= 1) return
const id = window.setInterval(() => {
setActive((current) => (current + 1) % images.length)
}, interval)
return () => window.clearInterval(id)
}, [images.length, interval])
return (
<div
className={cn(
"relative flex h-full w-full items-start bg-white px-3 pb-3 lg:min-h-[150px] lg:items-stretch dark:bg-card",
className
)}
>
<div className="relative aspect-video w-full overflow-hidden rounded-xl bg-muted/40 lg:aspect-auto">
{images.map((image, index) => {
const { src, darkSrc } =
typeof image === "string"
? { src: image, darkSrc: undefined }
: image
const isActive = index === active
return (
<div key={src} aria-hidden={!isActive}>
<img
src={src}
alt={isActive ? alt : ""}
loading="lazy"
className={cn(
"absolute inset-0 size-full object-contain object-center transition-[opacity,transform] duration-[1200ms] ease-out will-change-transform motion-reduce:transition-none",
isActive ? "scale-100 opacity-100" : "scale-105 opacity-0",
darkSrc && "dark:hidden"
)}
/>
{darkSrc && (
<img
src={darkSrc}
alt={isActive ? alt : ""}
loading="lazy"
className={cn(
"absolute inset-0 hidden size-full object-contain object-center transition-[opacity,transform] duration-[1200ms] ease-out will-change-transform motion-reduce:transition-none dark:block",
isActive ? "scale-100 opacity-100" : "scale-105 opacity-0"
)}
/>
)}
</div>
)
})}
</div>
</div>
)
}
src/components/animation/MarqueeImages.tsx
"use client"
// project-imports
import { cn } from "@/lib/utils"
// types
interface MarqueeImage {
src: string
darkSrc?: string
}
interface MarqueeImagesProps {
images: (string | MarqueeImage)[]
alt?: string
duration?: number
className?: string
}
// ------------------------------ | MARQUEE IMAGES | ------------------------------ //
export default function MarqueeImages({
images,
alt = "",
duration = 22,
className,
}: MarqueeImagesProps) {
const strip = [...images, ...images]
// Tiles are sized by height + `aspect-video`, so a 16:9 screenshot fills the
// tile exactly (no crop) and both rows always fit inside the card.
// The spacing is `mr-3` rather than a flex `gap` so the strip measures exactly
// 2 x (tile + gap) per pair — `translateX(-50%)` then lands on a seamless
// boundary instead of half a gap short.
const tileClass =
"mr-3 h-24 aspect-video shrink-0 overflow-hidden rounded-md border border-border bg-muted/40 sm:h-28 lg:h-32"
const row = (direction: "left" | "right") => (
<div
className="flex w-max items-center will-change-transform motion-reduce:animate-none"
style={{ animation: `marquee-${direction} ${duration}s linear infinite` }}
>
{strip.map((image, index) => {
const { src, darkSrc } =
typeof image === "string" ? { src: image, darkSrc: undefined } : image
const hidden = index >= images.length
return (
<div key={`${direction}-${src}-${index}`} className={tileClass}>
<img
src={src}
alt={hidden ? "" : alt}
aria-hidden={hidden}
loading="lazy"
className={cn(
"size-full object-contain",
darkSrc && "dark:hidden"
)}
/>
{darkSrc && (
<img
src={darkSrc}
alt={hidden ? "" : alt}
aria-hidden={hidden}
loading="lazy"
className="hidden size-full object-contain dark:block"
/>
)}
</div>
)
})}
</div>
)
return (
<div
className={cn(
"relative flex h-full min-h-[14rem] w-full items-center overflow-hidden bg-white lg:min-h-[18rem] dark:bg-card",
className
)}
>
<div className="flex flex-col gap-3">
{row("left")}
{row("right")}
</div>
<style>{`
@keyframes marquee-left {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
@keyframes marquee-right {
from { transform: translateX(-50%); }
to { transform: translateX(0); }
}
`}</style>
</div>
)
}
src/components/animation/TestimonialsMarquee.tsx
"use client"
// project-imports
import { cn } from "@/lib/utils"
// types
interface TestimonialImage {
src: string
darkSrc?: string
}
interface TestimonialsMarqueeProps {
/** One or two screenshots (light/dark pair) — the column is duplicated for a seamless loop. */
images: (string | TestimonialImage)[]
alt?: string
/** Seconds for one full loop. Lower = faster. */
duration?: number
className?: string
}
// ------------------------------ | TESTIMONIALS MARQUEE | ------------------------------ //
export default function TestimonialsMarquee({
images,
alt = "",
duration = 20,
className,
}: TestimonialsMarqueeProps) {
const copy = (keyPrefix: string, hidden: boolean) =>
images.map((image, index) => {
const { src, darkSrc } =
typeof image === "string" ? { src: image, darkSrc: undefined } : image
return (
<div
key={`${keyPrefix}-${index}`}
className="mb-2 h-auto w-full shrink-0 overflow-hidden rounded-lg bg-muted/40"
>
<img
src={src}
alt={hidden ? "" : alt}
aria-hidden={hidden}
loading="lazy"
className={cn(
"block h-auto w-full object-contain",
darkSrc && "dark:hidden"
)}
/>
{darkSrc && (
<img
src={darkSrc}
alt={hidden ? "" : alt}
aria-hidden={hidden}
loading="lazy"
className="hidden h-auto w-full object-contain dark:block"
/>
)}
</div>
)
})
return (
<div
className={cn(
"group relative h-full min-h-[15rem] w-full overflow-hidden bg-white px-2 pb-2 lg:min-h-[150px] dark:bg-card",
className
)}
>
<div
className="absolute inset-x-2 top-0 flex flex-col will-change-transform group-hover:[animation-play-state:paused] motion-reduce:animate-none"
style={{ animation: `testimonials-up ${duration}s linear infinite` }}
>
{copy("a", false)}
{copy("b", true)}
</div>
<style>{`
@keyframes testimonials-up {
from { transform: translateY(0); }
to { transform: translateY(-50%); }
}
`}</style>
</div>
)
}
src/components/uiable/blocks/landing/components/SectionHeader.tsx
// third-party
import { cn } from "cn"
// types
interface SectionHeaderProps {
title: string
subtitle: string
className?: string
titleClassName?: string
subtitleClassName?: string
}
// ------------------------------ | BLOCK - SECTION HEADER | ------------------------------ //
export default function SectionHeader({
title,
subtitle,
className,
titleClassName,
subtitleClassName,
}: SectionHeaderProps) {
return (
<div className={cn("flex flex-col gap-2 md:gap-3", className)}>
<h2
className={cn(
"text-2xl leading-tight font-medium tracking-normal text-foreground sm:text-3xl sm:leading-8 md:text-4xl md:leading-9",
titleClassName
)}
>
{title}
</h2>
<p
className={cn(
"text-base leading-relaxed font-normal tracking-normal text-muted-foreground sm:text-lg",
subtitleClassName
)}
>
{subtitle}
</p>
</div>
)
}