block
Storefront — Home
Hero, featured collections and a product grid.
npx shadcn@latest add @hextor/store-homeSource
import Image from "next/image";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Marquee, MarqueeStyle } from "@/registry/hextor/components/store/storefront/marquee";
import { ProductGrid } from "@/registry/hextor/components/store/storefront/product-grid";
import { ScrollRevealStyle } from "@/registry/hextor/components/store/storefront/scroll-reveal";
import {
ScrollStory,
ScrollStoryStyle,
type ScrollStoryPanel,
} from "@/registry/hextor/components/store/storefront/scroll-story";
import { SectionHeading } from "@/registry/hextor/components/store/storefront/section-heading";
import { GrainOverlay } from "@/registry/hextor/components/store/storefront/texture";
import {
StoreHeader,
type StoreHeaderProps,
} from "@/registry/hextor/components/store/storefront/store-header";
import {
StoreFooter,
type StoreFooterProps,
} from "@/registry/hextor/components/store/storefront/store-footer";
import { cn } from "@/registry/hextor/lib/utils";
import type { Collection, Product, ProductImage } from "@/registry/hextor/lib/store";
import { resolveLabels, type HomeLabels } from "@/registry/hextor/components/store/storefront/home-labels";
/** Top announcement bar — a rotating promise band, the same claims already made in the hero subheading and `ProductDisclosures` rather than new ones invented for this slot. */
const DEFAULT_ANNOUNCEMENTS = ["Free shipping over ฿1,500", "Easy 30-day returns", "New arrivals every week"];
/** Sample brand narrative for the mid-page `ScrollStory` — generic, non-fabricated copy in the same spirit as the hero's own sample tagline; swap for the store's real story. */
const DEFAULT_STORY_PANELS: ScrollStoryPanel[] = [
{
id: "fabric",
eyebrow: "Fabric",
heading: "Built from cloth that earns its keep",
body: "Every fabric is washed and worn before it ever reaches a cutting table — not just tested in a lab.",
},
{
id: "construction",
eyebrow: "Construction",
heading: "Seams that don't give up first",
body: "Reinforced stress points and finished edges, so the stitching outlasts the fabric around it.",
},
{
id: "shipping",
eyebrow: "Shipping",
heading: "Packed to be opened, not unwrapped",
body: "Reusable mailers, minimal inserts, and a box sized for what's actually inside.",
},
];
export interface HomePageProps {
hero: {
eyebrow?: string;
heading: string;
subheading?: string;
image?: ProductImage;
ctaHref: string;
};
featuredCollections: Collection[];
featuredProducts: Product[];
collectionHrefFor?: (collection: Collection) => string;
/**
* Builds the link for a product card. Defaults to `/products/{handle}`,
* which is right for a store mounted at the root and wrong for one mounted
* anywhere else — a demo under /demo, a shop under /shop, a locale prefix.
* Threading it as a prop is what lets the same view serve all of them; the
* alternative is rewriting hrefs in the DOM after render, which loses to a
* click that lands before the script does.
*/
productHrefFor?: (product: Product) => string;
collectionsHref?: string;
productsHref?: string;
header: StoreHeaderProps;
footer: StoreFooterProps;
/** The scrolling promise band above the header. Pass `[]` to turn it off. */
announcements?: string[];
/** The Apple-style sticky-visual narrative between the collections and products grids. Pass `[]` to turn it off. */
storyPanels?: ScrollStoryPanel[];
labels?: Partial<HomeLabels>;
className?: string;
}
/**
* The storefront landing page: hero, featured collections, a product grid.
* Data arrives entirely through props — no fetch, no router hook — so this
* file works unmodified whether the consumer's data comes from Shopify's
* Storefront API, a headless CMS, or the bundled fixtures.
*
* The hero is a real two-column composition rather than a coloured box: copy
* on one side, an "art panel" on the other. When `hero.image` is missing (the
* template's own default — see `routes/home.tsx`) that panel doesn't just
* disappear, it becomes the store's own name set at poster scale and cropped
* by the frame, so the very first thing installed still reads as a designed
* page rather than an empty placeholder waiting for a photographer.
*/
export default function HomePage({
hero,
featuredCollections,
featuredProducts,
collectionHrefFor,
productHrefFor,
collectionsHref = "/collections",
productsHref = "/products",
header,
footer,
announcements = DEFAULT_ANNOUNCEMENTS,
storyPanels = DEFAULT_STORY_PANELS,
labels,
className,
}: HomePageProps) {
const copy = resolveLabels(labels);
return (
<div className={cn("flex min-h-screen flex-col", className)}>
<ScrollRevealStyle />
<MarqueeStyle />
<ScrollStoryStyle />
<Marquee
items={announcements}
ariaLabel="Store announcements"
className="border-b border-foreground/10 bg-foreground py-2 text-xs font-medium tracking-wide text-background uppercase"
/>
<StoreHeader {...header} />
<main className="flex flex-1 flex-col gap-20 pb-20">
<section className="relative overflow-hidden">
<div className="mx-auto grid w-full max-w-6xl grid-cols-1 items-center gap-10 px-4 py-14 sm:px-6 lg:grid-cols-[1.05fr_1fr] lg:gap-16 lg:py-24">
<div
className={cn(
"relative z-10 flex flex-col gap-6",
"animate-in fade-in slide-in-from-bottom-6 duration-700 ease-out fill-mode-both motion-reduce:animate-none"
)}
>
{hero.eyebrow && (
<div className="flex items-center gap-2.5">
<span aria-hidden="true" className="h-[3px] w-8 rounded-full bg-brand" />
<span className="text-xs font-semibold tracking-[0.28em] text-muted-foreground uppercase">
{hero.eyebrow}
</span>
</div>
)}
<h1 className="max-w-xl text-4xl leading-[1.02] font-semibold tracking-tight text-balance sm:text-6xl lg:text-[4.25rem] lg:leading-[0.98]">
{hero.heading}
</h1>
{hero.subheading && (
<p className="max-w-md text-base leading-relaxed text-muted-foreground sm:text-lg">
{hero.subheading}
</p>
)}
<div>
<Button
size="lg"
className="bg-brand text-brand-foreground hover:bg-brand/90"
render={<Link href={hero.ctaHref}>{copy.heroCta}</Link>}
/>
</div>
</div>
<div className="relative aspect-[16/11] w-full sm:aspect-[4/3] lg:aspect-[4/5]">
<div
aria-hidden="true"
className="absolute -inset-3 -z-10 rounded-[2rem] bg-muted sm:-inset-4"
/>
<div className="relative h-full w-full overflow-hidden rounded-3xl bg-brand-subtle ring-1 ring-foreground/10">
{hero.image ? (
<Image
src={hero.image.url}
alt={hero.image.alt}
fill
priority
sizes="(min-width: 1024px) 45vw, 100vw"
className="object-cover"
/>
) : (
/**
* Anchored left and bleeding off ONE edge. Centred, it was
* cropped on both sides — "HextorStore" rendered as
* "lextorStor", which reads as text overflowing a box rather
* than as a wordmark deliberately run past the frame. A crop
* only looks intentional when the reader can see where it
* started.
*/
<div className="flex h-full w-full items-center overflow-hidden">
<span
aria-hidden="true"
className="-rotate-2 -ml-2 text-nowrap font-heading text-[4.5rem] leading-none font-semibold tracking-tighter text-brand/15 select-none sm:text-[6rem] lg:text-[5.5rem]"
>
{header.storeName}
</span>
</div>
)}
<GrainOverlay />
</div>
</div>
</div>
</section>
{featuredCollections.length > 0 && (
<section className="mx-auto flex w-full max-w-6xl flex-col gap-6 px-4 sm:px-6">
<SectionHeading
title={copy.featuredCollectionsHeading}
action={
<Link
href={collectionsHref}
className="text-sm text-muted-foreground transition-colors hover:text-brand"
>
{copy.viewAllLabel}
</Link>
}
/>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
{featuredCollections.map((collection) => (
<Link
key={collection.id}
href={collectionHrefFor?.(collection) ?? `/collections/${collection.handle}`}
className="group reveal-up block"
>
<Card className="gap-0 overflow-hidden py-0 transition-shadow duration-300 group-hover:shadow-lg">
<div className="relative aspect-4/3 overflow-hidden bg-muted">
{collection.image && (
<Image
src={collection.image.url}
alt={collection.image.alt}
fill
sizes="(min-width: 1024px) 25vw, (min-width: 640px) 33vw, 50vw"
className="object-cover transition-transform duration-500 ease-out group-hover:scale-105 motion-reduce:transition-none motion-reduce:group-hover:scale-100"
/>
)}
</div>
<div className="px-3 py-2.5">
<span className="text-sm font-medium">{collection.title}</span>
</div>
</Card>
</Link>
))}
</div>
</section>
)}
<section className="mx-auto flex w-full max-w-6xl flex-col gap-6 px-4 sm:px-6">
<SectionHeading
title={copy.featuredProductsHeading}
action={
<Link
href={productsHref}
className="text-sm text-muted-foreground transition-colors hover:text-brand"
>
{copy.viewAllLabel}
</Link>
}
/>
<ProductGrid products={featuredProducts} hrefFor={productHrefFor} featured />
</section>
{/* Story last, on purpose. A storefront's job is to get someone to the
goods; brand narrative earns attention after they have seen them,
not by standing between them and the grid. When this sat above the
products it put roughly four screens of near-empty sticky scroll in
the way. */}
<ScrollStory panels={storyPanels} ariaLabel="Our story" />
</main>
<StoreFooter {...footer} />
</div>
);
}
Docs
The landing page. All data arrives through props on the view, so swapping the fixtures in the route file for your own queries is the only change it needs.
Ships with sample data from @hextor/store-fixtures so it renders a real shop the moment it installs — a template you have to wire up before you can look at it is a template nobody evaluates. The route file is the only place that import appears.
Dependencies
@hextor/store-ui@hextor/store-fixtures