# CTA Split Photo

> A photo split call to action with numbered reasons and bookable demo times.

A shadcn/ui block from the [@devsloka](https://ui.devsloka.in/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/devsloka/cta-split-photo).

- Page, with a live preview: https://shadcn.exchange/r/devsloka/cta-split-photo
- Screenshot: https://shadcn.exchange/shot/devsloka/cta-split-photo/light.png
- Registry item: https://ui.devsloka.in/r/cta-split-photo.json
- Listed: 2026-10-28, source last changed 2026-10-28

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/devsloka/cta-split-photo.json
```

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

## Dependencies

- Registry items: `button`

## Files

### `src/registry/blocks/cta/cta-split-photo.tsx`

```tsx
"use client";

import { useId, useState, type ComponentProps, type CSSProperties } from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";

type Action = { label: string; href: string };

export type CtaSlot = {
  id: string;
  /** Short day label, e.g. "Tue 13 Oct". */
  day: string;
  /** Time label, e.g. "10:30". */
  time: string;
};

export type CtaSplitPhotoProps = {
  eyebrow?: string;
  title?: string;
  description?: string;
  /** Up to three or four short, concrete reasons. Rendered as a numbered list. */
  reasons?: string[];
  image?: { src: string; alt: string; caption?: string };
  /** Which side the photograph sits on from the `lg` breakpoint. */
  photoSide?: "left" | "right";
  /** The primary action. When a slot is selected, its label reads "Book <day>, <time>". */
  primaryAction?: Action;
  secondaryAction?: Action;
  /** Optional bookable times. Pass an empty array to hide the list. */
  slots?: CtaSlot[];
  slot?: string;
  defaultSlot?: string;
  onSlotChange?: (slotId: string) => void;
  timezoneNote?: string;
  /** Background of the offer panel. Defaults to a pale blue tint. */
  surface?: string;
  /** Text colour on the offer panel. Must pass contrast against `surface`. */
  surfaceForeground?: string;
  /** Second ink for numbers, selected slot and primary button. Defaults to Devsloka blue. */
  accent?: string;
  /** Text colour on `accent`. */
  accentForeground?: string;
} & ComponentProps<"section">;

const DEFAULTS = {
  eyebrow: "Wickham · Software for small holiday lets",
  title: "Talk to someone who has cleaned a room between guests.",
  description:
    "Book a 20-minute walkthrough with Ana or Tomás. Both ran guesthouses before joining Wickham. Bring your current calendar and we will import it while you watch.",
  reasons: [
    "One calendar for Airbnb, Booking and your own site, synced every 5 minutes.",
    "Cleaning rotas that text your cleaner the moment a checkout is confirmed.",
    "Tourist tax and invoices filed per property, ready for your accountant in April.",
  ],
  image: {
    src: "https://images.unsplash.com/photo-1583847268964-b28dc8f51f92?w=1200&q=80",
    alt: "A cosy living room interior with a sofa, cushions and soft daylight",
    caption: "Casa Ribeira, Porto. Four rooms, run on Wickham since 2024.",
  },
  primaryAction: { label: "Book a walkthrough", href: "#" },
  secondaryAction: { label: "Watch the 6-minute tour", href: "#" },
  slots: [
    { id: "tue-1030", day: "Tue 13 Oct", time: "10:30" },
    { id: "tue-1600", day: "Tue 13 Oct", time: "16:00" },
    { id: "wed-0930", day: "Wed 14 Oct", time: "09:30" },
    { id: "thu-1400", day: "Thu 15 Oct", time: "14:00" },
  ],
  timezoneNote: "Times in Lisbon (WEST). Calls run on Google Meet or phone.",
  surface: "#E8F0FF",
  surfaceForeground: "#0A0A0A",
  accent: "#0A6EFF",
  accentForeground: "#FFFFFF",
};

const pad = (n: number) => String(n).padStart(2, "0");

export function CtaSplitPhoto({
  eyebrow = DEFAULTS.eyebrow,
  title = DEFAULTS.title,
  description = DEFAULTS.description,
  reasons = DEFAULTS.reasons,
  image = DEFAULTS.image,
  photoSide = "left",
  primaryAction = DEFAULTS.primaryAction,
  secondaryAction = DEFAULTS.secondaryAction,
  slots = DEFAULTS.slots,
  slot,
  defaultSlot,
  onSlotChange,
  timezoneNote = DEFAULTS.timezoneNote,
  surface = DEFAULTS.surface,
  surfaceForeground = DEFAULTS.surfaceForeground,
  accent = DEFAULTS.accent,
  accentForeground = DEFAULTS.accentForeground,
  className,
  style,
  ...props
}: CtaSplitPhotoProps) {
  const headingId = useId();
  const slotsLabelId = useId();
  const [uncontrolledSlot, setUncontrolledSlot] = useState(defaultSlot);
  const currentSlotId = slot ?? uncontrolledSlot;
  const currentSlot = slots.find((s) => s.id === currentSlotId);

  const selectSlot = (id: string) => {
    if (slot === undefined) setUncontrolledSlot(id);
    onSlotChange?.(id);
  };

  const primaryHref = currentSlot
    ? `${primaryAction.href}${primaryAction.href.includes("?") ? "&" : "?"}slot=${encodeURIComponent(currentSlot.id)}`
    : primaryAction.href;

  return (
    <section
      data-slot="cta-split-photo"
      aria-labelledby={headingId}
      style={
        {
          "--block-surface": surface,
          "--block-surface-fg": surfaceForeground,
          "--block-accent": accent,
          "--block-accent-fg": accentForeground,
          ...style,
        } as CSSProperties
      }
      className={cn("w-full bg-background text-foreground", className)}
      {...props}
    >
      <div className="mx-auto max-w-6xl px-6 py-20 md:py-28">
        <div className="grid lg:grid-cols-12">
          <figure className={cn("flex flex-col lg:col-span-5", photoSide === "right" && "lg:order-last")}>
            {/* eslint-disable-next-line @next/next/no-img-element -- plain <img> keeps the block portable outside Next.js */}
            <img
              src={image.src}
              alt={image.alt}
              width={1200}
              height={1500}
              loading="lazy"
              className="aspect-[4/3] w-full flex-1 object-cover sm:aspect-[16/10] lg:aspect-auto"
            />
            {image.caption && (
              <figcaption className="border-x border-b px-4 py-3 font-mono text-[11px] leading-relaxed text-muted-foreground lg:border-x-0 lg:px-0">
                {image.caption}
              </figcaption>
            )}
          </figure>

          <div className="bg-[var(--block-surface)] px-6 py-10 text-[var(--block-surface-fg)] sm:px-10 md:py-14 lg:col-span-7 lg:px-14">
            <p className="font-mono text-xs uppercase tracking-wider">{eyebrow}</p>
            <h2
              id={headingId}
              className="mt-6 max-w-xl text-3xl font-semibold leading-[1.05] tracking-[-0.03em] text-balance md:text-5xl"
            >
              {title}
            </h2>
            <p className="mt-6 max-w-lg text-base leading-relaxed">{description}</p>

            {reasons.length > 0 && (
              <ol className="mt-8 max-w-lg divide-y divide-[var(--block-surface-fg)]/25 border-y border-[var(--block-surface-fg)]/25">
                {reasons.map((reason, i) => (
                  <li key={reason} className="grid grid-cols-[2.5rem_1fr] gap-x-2 py-3 text-sm leading-relaxed">
                    <span aria-hidden className="font-mono text-xs font-semibold tabular-nums leading-6 text-[var(--block-accent)]">
                      {pad(i + 1)}
                    </span>
                    <span>{reason}</span>
                  </li>
                ))}
              </ol>
            )}

            {slots.length > 0 && (
              <div className="mt-8">
                <p id={slotsLabelId} className="font-mono text-[11px] uppercase tracking-wider">
                  Next available
                </p>
                <div role="group" aria-labelledby={slotsLabelId} className="mt-3 grid grid-cols-2 gap-2 sm:grid-cols-4">
                  {slots.map((s) => {
                    const selected = s.id === currentSlotId;
                    return (
                      <button
                        key={s.id}
                        type="button"
                        aria-pressed={selected}
                        onClick={() => selectSlot(s.id)}
                        className={cn(
                          "flex flex-col items-start border border-[var(--block-accent)] px-3 py-2 text-left outline-none transition-colors focus-visible:ring-2 focus-visible:ring-[var(--block-accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--block-surface)]",
                          selected
                            ? "bg-[var(--block-accent)] text-[var(--block-accent-fg)]"
                            : "hover:bg-[var(--block-surface-fg)]/10"
                        )}
                      >
                        <span className="font-mono text-[11px] uppercase tracking-wider">{s.day}</span>
                        <span className="text-base font-semibold tabular-nums">{s.time}</span>
                      </button>
                    );
                  })}
                </div>
                {timezoneNote && <p className="mt-3 text-xs leading-relaxed">{timezoneNote}</p>}
              </div>
            )}

            <div className="mt-10 flex flex-wrap items-center gap-x-6 gap-y-3">
              <Button
                asChild
                size="lg"
                className="rounded-none bg-[var(--block-accent)] text-[var(--block-accent-fg)] shadow-none hover:bg-[var(--block-accent)] hover:underline hover:underline-offset-4 focus-visible:ring-[var(--block-accent)]"
              >
                <a href={primaryHref}>
                  {currentSlot ? `Book ${currentSlot.day}, ${currentSlot.time}` : primaryAction.label}
                </a>
              </Button>
              <a
                href={secondaryAction.href}
                className="text-sm font-medium underline decoration-1 underline-offset-[6px] outline-none hover:decoration-2 focus-visible:ring-2 focus-visible:ring-[var(--block-accent)]"
              >
                {secondaryAction.label}
              </a>
            </div>
            <p aria-live="polite" className="sr-only">
              {currentSlot ? `Selected ${currentSlot.day} at ${currentSlot.time}.` : ""}
            </p>
          </div>
        </div>
      </div>
    </section>
  );
}
```
