گالریِ قاب و نوار
پیشنمایش تمامصفحهبخشِ گالری: قابِ بزرگ با تیتر و توضیح و نوارِ تصاویرِ کوچک زیرش؛ انتخابِ هر تصویر قاب را عوض میکند و نوارِ زمان خودکار به بعدی میرود.
۱. نصب کامپوننتها
این بلاک از ۳ آیتم دیگ استفاده میکند: chipcontrol-sizereveal
پکیجمنیجر پیشفرض Node.jsنصب سریع با لینک سخت و دیسک مشترکYarn نسخهٔ ۲ به بالا (Berry)رانتایم و پکیجمنیجر Bun
npx digdesign@latest add https://docs.digdesign.ir/r/chip.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/reveal.jsonpnpm dlx digdesign@latest add https://docs.digdesign.ir/r/chip.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/reveal.jsonyarn dlx digdesign@latest add https://docs.digdesign.ir/r/chip.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/reveal.jsonbunx --bun digdesign@latest add https://docs.digdesign.ir/r/chip.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/reveal.json۲. کد صفحه
کد را در app/<route>/page.tsx بگذارید و متنها، داده و هندلرها را با محصول خودتان عوض کنید.
gallery-filmstrip.tsx
"use client";
import * as React from "react";
import { Chip } from "@/components/ui/chip";
import { ControlSizeProvider } from "@/components/ui/control-size";
import { Reveal } from "@/components/ui/reveal";
import { cn } from "@/lib/utils";
import { stockImages, type StockImageName } from "@/lib/stock-images";
const FRAMES: { photo: StockImageName; title: string; text: string }[] = [
{
photo: "projectDashboard",
title: "داشبوردِ فروش",
text: "گزارشِ روزانه در یک نگاه، بدونِ خروجیِ اکسل.",
},
{
photo: "projectMobileApp",
title: "اپِ موبایل",
text: "سفارشها و پیامها را در راه هم جواب بدهید.",
},
{
photo: "projectCafeBrand",
title: "هویتِ بصری",
text: "منو، بستهبندی و تابلو با یک زبانِ بصری.",
},
{
photo: "coverWorkspace",
title: "کارِ تیمی",
text: "هر کس میداند امروز نوبتِ کدام کار است.",
},
];
/**
* گالریِ قاب و نوار: عکسِ بزرگ با تیتر و توضیحِ روی آن، و زیرش نوارِ تصاویرِ کوچک که
* با انتخاب، قابِ بزرگ عوض میشود؛ نوارِ زمان روی تصویرِ کوچکِ فعال پر میشود و
* خودکار به بعدی میرود.
*/
export default function GalleryFilmstripBlock() {
const [active, setActive] = React.useState(0);
const [paused, setPaused] = React.useState(false);
const frame = FRAMES[active];
React.useEffect(() => {
if (
paused ||
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches
)
return;
const timer = window.setTimeout(
() => setActive((current) => (current + 1) % FRAMES.length),
5000,
);
return () => window.clearTimeout(timer);
}, [active, paused]);
return (
<ControlSizeProvider size="xl" asChild>
<section
aria-labelledby="fs-title"
className="mx-auto flex w-full max-w-6xl flex-col gap-10 px-4 py-24 sm:px-6"
>
<div className="flex max-w-2xl flex-col gap-3">
<span className="text-sm font-semibold text-brand-secondary">
محصول
</span>
<h2
id="fs-title"
className="text-3xl leading-snug font-black text-balance sm:text-4xl"
>
همهچیزِ کسبوکار، در چهار قاب
</h2>
</div>
<Reveal
className="flex flex-col gap-4"
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
>
<div className="relative aspect-[4/5] overflow-hidden rounded-xl bg-muted sm:aspect-[16/8]">
{FRAMES.map((item, index) => (
// eslint-disable-next-line @next/next/no-img-element -- عکس از کتابخانهٔ تصویرِ دیگ
<img
key={item.photo}
src={stockImages[item.photo].src}
alt={index === active ? stockImages[item.photo].alt : ""}
aria-hidden={index !== active}
className={cn(
"absolute inset-0 size-full object-cover transition-[opacity,scale] duration-700",
index === active ? "scale-100" : "scale-105 [opacity:0]",
)}
/>
))}
<div
aria-live="polite"
className="absolute inset-x-0 bottom-0 flex flex-col gap-2 bg-linear-to-t from-[var(--dig-graphite-950)] to-transparent p-6 pt-24 text-[var(--dig-white)] sm:p-10 sm:pt-32"
>
<h3 className="text-2xl font-black sm:text-3xl">{frame.title}</h3>
<p className="max-w-md leading-7 text-[var(--dig-neutral-300)]">
{frame.text}
</p>
</div>
</div>
<div
role="tablist"
aria-label="قابها"
className="grid grid-cols-4 gap-2 sm:gap-4"
>
{FRAMES.map((item, index) => (
<Chip asChild key={item.photo} variant="secondary">
<button
type="button"
role="tab"
aria-selected={index === active}
aria-label={item.title}
onClick={() => setActive(index)}
className={cn(
"group relative block h-auto w-full overflow-hidden rounded-lg border-0 bg-transparent p-0 text-start text-base whitespace-normal outline-none ring-offset-2 ring-offset-background focus-visible:ring-2 focus-visible:ring-ring",
index === active ? "ring-2 ring-foreground" : "",
)}
>
{/* eslint-disable-next-line @next/next/no-img-element -- عکس از کتابخانهٔ تصویرِ دیگ */}
<img
src={stockImages[item.photo].src}
alt=""
className={cn(
"aspect-[16/10] w-full object-cover transition-[filter]",
index !== active && "grayscale group-hover:grayscale-0",
)}
/>
<span
aria-hidden
className="absolute inset-x-0 bottom-0 h-1 bg-[color-mix(in_oklab,var(--dig-white)_40%,transparent)]"
>
{index === active && (
<span
key={`${active}-${paused}`}
className={cn(
"block h-full origin-right bg-[var(--dig-white)]",
!paused && "animate-[fs-fill_5s_linear_forwards]",
)}
/>
)}
</span>
<span className="hidden p-2 text-start text-sm font-semibold sm:block">
{item.title}
</span>
</button>
</Chip>
))}
</div>
</Reveal>
<style>
{
"@keyframes fs-fill{from{transform:scaleX(0)}to{transform:scaleX(1)}}"
}
</style>
</section>
</ControlSizeProvider>
);
}