# Hero Cover Photo

> A full-bleed photo with the title, a small label, a short description, and one call to action along its bottom edge

A shadcn/ui block from the [@cortexcn](https://www.cortexcn.dev/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/cortexcn/hero-cover-01).

- Page, with a live preview: https://shadcn.exchange/r/cortexcn/hero-cover-01
- Screenshot: https://shadcn.exchange/shot/cortexcn/hero-cover-01/light.png
- Registry item: https://ui.cortexcn.dev/r/hero-cover-01.json
- Listed: 2026-11-05, source last changed 2026-11-05

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/cortexcn/hero-cover-01.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `class-variance-authority`, `motion`
- Registry items: `https://ui.cortexcn.dev/r/utils.json`, `button`

## Files

### `registry/blocks/hero-cover-01/components/hero-cover.tsx`

```tsx
"use client";

import { motion, useReducedMotion, type Variants } from "motion/react";
import type { ReactNode } from "react";

import { cn } from "@/lib/utils";

import { Cta, type CtaProps } from "./hero-cta";

// Cortexcn item "hero-cover-01", revision 912eb1a56a04, MIT license.
// https://ui.cortexcn.dev/r/v/hero-cover-01/912eb1a56a04.json

export interface HeroCoverProps {
  title?: string;
  /** Small caps label beside the title, such as a founding year. */
  established?: string;
  description?: string;
  /** Full-bleed photo, darkened toward the bottom so the copy reads. */
  backgroundImage?: string;
  backgroundAlt?: string;
  animation?: "none" | "subtle";
  primaryCTA?: CtaProps;
  variant?: "standard" | "compact";
}

const DEFAULTS = {
  title: "Aspen Ridge",
  established: "Est. 2019",
  description:
    "A secluded mountain retreat set among the aspens, with floor-to-ceiling windows framing uninterrupted views of the Rockies. Wake to still water, quiet forest, and nothing on the schedule.",
  // Sample photo from Unsplash (free to use under the Unsplash License).
  backgroundImage:
    "https://images.unsplash.com/photo-1626849440347-a491d028c0cf?q=80&w=1631&auto=format&fit=crop",
  backgroundAlt: "A calm mountain lake at dusk beneath a forested ridge",
  primaryCTA: {
    ctaEnabled: true,
    text: "Reserve a Stay",
    link: "",
    size: "default",
  },
} satisfies HeroCoverProps;

const variantStyles = {
  standard: {
    section: "min-h-[560px] sm:min-h-[600px]",
    padding: "px-6 py-10 sm:px-10 sm:py-12",
    title: "text-4xl sm:text-5xl md:text-6xl",
    established: "text-xs sm:text-sm",
    description: "max-w-lg text-sm sm:text-base",
    copy: "gap-5 sm:gap-6",
  },
  compact: {
    section: "min-h-[440px] sm:min-h-[480px]",
    padding: "px-6 py-8 sm:px-8 sm:py-10",
    title: "text-3xl sm:text-4xl md:text-5xl",
    established: "text-[0.65rem] sm:text-xs",
    description: "max-w-md text-sm",
    copy: "gap-4 sm:gap-5",
  },
} as const;

const container: Variants = {
  hidden: {},
  visible: { transition: { staggerChildren: 0.12, delayChildren: 0.1 } },
};

const item: Variants = {
  hidden: { opacity: 0, y: 16, filter: "blur(6px)" },
  visible: {
    opacity: 1,
    y: 0,
    filter: "blur(0px)",
    transition: { duration: 0.5, ease: [0.22, 1, 0.36, 1] },
  },
};

const media: Variants = {
  hidden: { opacity: 0, scale: 1.08 },
  visible: {
    opacity: 1,
    scale: 1,
    transition: { duration: 0.8, ease: [0.22, 1, 0.36, 1] },
  },
};

function Reveal({
  active,
  variants,
  className,
  children,
}: Readonly<{
  active: boolean;
  variants?: Variants;
  className?: string;
  children: ReactNode;
}>) {
  if (!active) {
    return <div className={className}>{children}</div>;
  }

  return (
    <motion.div className={className} variants={variants ?? item}>
      {children}
    </motion.div>
  );
}

/**
 * A full-bleed photo with the title, a small label, a short description, and
 * one light call to action set along its bottom edge.
 */
export function HeroCover({
  title = DEFAULTS.title,
  established = DEFAULTS.established,
  description = DEFAULTS.description,
  backgroundImage = DEFAULTS.backgroundImage,
  backgroundAlt = DEFAULTS.backgroundAlt,
  animation = "subtle",
  primaryCTA = DEFAULTS.primaryCTA,
  variant = "standard",
}: Readonly<HeroCoverProps>) {
  const reduce = useReducedMotion();
  const animate = animation === "subtle" && !reduce;
  const vs = variantStyles[variant];

  return (
    <section
      className={cn(
        "relative isolate flex w-full flex-col justify-end overflow-hidden bg-background",
        vs.section,
      )}
    >
      {backgroundImage && (
        <div className="absolute inset-0 z-0 overflow-hidden">
          {/* biome-ignore lint/performance/noImgElement: framework-agnostic registry block */}
          <motion.img
            alt={backgroundAlt}
            className="size-full object-cover"
            decoding="async"
            height={1088}
            initial={animate ? "hidden" : false}
            src={backgroundImage}
            variants={animate ? media : undefined}
            viewport={{ once: true, margin: "-80px" }}
            whileInView={animate ? "visible" : undefined}
            width={1631}
          />
          <div
            aria-hidden="true"
            className="absolute inset-0 bg-linear-to-t from-black/70 via-black/25 to-black/10"
          />
        </div>
      )}

      <motion.div
        className={cn(
          "relative z-10 flex w-full flex-col gap-6 sm:flex-row sm:items-end sm:justify-between",
          vs.padding,
        )}
        initial={animate ? "hidden" : false}
        variants={animate ? container : undefined}
        viewport={{ once: true, margin: "-80px" }}
        whileInView={animate ? "visible" : undefined}
      >
        <Reveal active={animate} className="min-w-0">
          <div className={cn("flex flex-col", vs.copy)}>
            {title && (
              <div className="flex flex-wrap items-baseline gap-x-4 gap-y-1">
                <h1
                  className={cn(
                    "text-balance font-normal text-white tracking-tight",
                    vs.title,
                  )}
                >
                  {title}
                </h1>
                {established && (
                  <span
                    className={cn(
                      "font-medium text-white/70 uppercase tracking-widest",
                      vs.established,
                    )}
                  >
                    {established}
                  </span>
                )}
              </div>
            )}
            {description && (
              <p
                className={cn(
                  "text-balance text-white/80 leading-relaxed",
                  vs.description,
                )}
              >
                {description}
              </p>
            )}
          </div>
        </Reveal>

        {primaryCTA?.ctaEnabled && (
          <Reveal active={animate}>
            <Cta className="shrink-0" cta={primaryCTA} invert />
          </Reveal>
        )}
      </motion.div>
    </section>
  );
}
```

### `registry/blocks/hero-cover-01/components/hero-cta.tsx`

```tsx
import { cva, type VariantProps } from "class-variance-authority";

import { Button, buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";

// Cortexcn item "hero-cover-01", revision 912eb1a56a04, MIT license.
// https://ui.cortexcn.dev/r/v/hero-cover-01/912eb1a56a04.json

type ButtonVariants = VariantProps<typeof buttonVariants>;

export type ButtonVariant = NonNullable<ButtonVariants["variant"]>;

export interface CtaProps {
  ctaEnabled?: boolean;
  text: string;
  /** Leave empty to render a button instead of a link. */
  link: string;
  variant?: ButtonVariant;
  size?: "default" | "sm" | "lg";
}

const ctaVariants = cva("w-fit rounded-full px-4", {
  variants: {
    invert: {
      true: "border-transparent! bg-white! text-zinc-900! hover:bg-zinc-100!",
      false: "",
    },
  },
  defaultVariants: {
    invert: false,
  },
});

export function Cta({
  cta,
  className,
  invert,
}: Readonly<{ cta?: CtaProps; className?: string; invert?: boolean }>) {
  if (!cta?.ctaEnabled) {
    return null;
  }

  const variant = cta.variant ?? (invert ? "secondary" : "default");

  if (!cta.link) {
    return (
      <Button
        aria-label={cta.text}
        className={cn(ctaVariants({ invert }), className)}
        size={cta.size ?? "default"}
        type="button"
        variant={variant}
      >
        {cta.text}
      </Button>
    );
  }

  return (
    // No aria-label here, so screen readers also hear "(opens in new tab)".
    <a
      className={cn(
        buttonVariants({
          variant,
          size: cta.size ?? "default",
          className: cn(ctaVariants({ invert }), className),
        }),
      )}
      href={cta.link}
      rel="noopener noreferrer"
      target="_blank"
    >
      {cta.text}
      <span className="sr-only">{`${cta.text} (opens in new tab)`}</span>
    </a>
  );
}
```
