Web InnoventixFreeCode

Horizontal Card

Original · free

A horizontal media card with image beside body copy.

byWeb InnoventixReact + Tailwind
cardhorizontalcards
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/card-horizontal.json
card-horizontal.tsx
"use client";

import { useState } from "react";
import { useReducedMotion } from "motion/react";

type Article = {
  id: string;
  image: string;
  category: string;
  tint: string;
  title: string;
  excerpt: string;
  author: string;
  readTime: string;
  date: string;
  minutes: number;
};

type Product = {
  id: string;
  image: string;
  brand: string;
  name: string;
  blurb: string;
  price: number;
  compareAt: number;
  rating: number;
  reviews: number;
};

const ARTICLES: Article[] = [
  {
    id: "a1",
    image: "/img/gallery/g07.webp",
    category: "Engineering",
    tint: "indigo",
    title: "Shipping edge functions without cold starts",
    excerpt:
      "We moved our auth gateway to the edge and cut p95 latency from 340ms to 41ms. Here is the routing model, the cache keys, and the two mistakes that cost us a weekend.",
    author: "Priya Nair",
    readTime: "8 min read",
    date: "Jul 2, 2026",
    minutes: 8,
  },
  {
    id: "a2",
    image: "/img/gallery/g19.webp",
    category: "Design",
    tint: "violet",
    title: "The quiet power of a 4px baseline grid",
    excerpt:
      "Consistency is not a vibe, it is arithmetic. A single spacing unit turned our design reviews from taste debates into checklists, and shipped a calmer product.",
    author: "Marcus Webb",
    readTime: "5 min read",
    date: "Jun 28, 2026",
    minutes: 5,
  },
];

const PRODUCT: Product = {
  id: "p1",
  image: "/img/gallery/g24.webp",
  brand: "Auralite",
  name: "Field Pro Wireless Headphones",
  blurb:
    "40-hour battery, adaptive noise cancelling, and a machined aluminium frame that survives a backpack. Tuned for long sessions, not showroom demos.",
  price: 249,
  compareAt: 329,
  rating: 4.6,
  reviews: 1284,
};

const PREFIX = "chz";

function Stars({ rating }: { rating: number }) {
  return (
    <span className="inline-flex items-center gap-0.5" aria-hidden="true">
      {[0, 1, 2, 3, 4].map((i) => {
        const fill = Math.max(0, Math.min(1, rating - i));
        return (
          <span key={i} className="relative inline-block h-3.5 w-3.5">
            <svg viewBox="0 0 20 20" className="absolute inset-0 h-full w-full text-slate-300 dark:text-zinc-700">
              <path
                fill="currentColor"
                d="M10 1.6l2.47 5.01 5.53.8-4 3.9.94 5.5L10 14.2l-4.95 2.6.95-5.5-4-3.9 5.53-.8z"
              />
            </svg>
            <span className="absolute inset-0 overflow-hidden" style={{ width: `${fill * 100}%` }}>
              <svg viewBox="0 0 20 20" className="h-full w-3.5 text-amber-400">
                <path
                  fill="currentColor"
                  d="M10 1.6l2.47 5.01 5.53.8-4 3.9.94 5.5L10 14.2l-4.95 2.6.95-5.5-4-3.9 5.53-.8z"
                />
              </svg>
            </span>
          </span>
        );
      })}
    </span>
  );
}

const TINT: Record<string, string> = {
  indigo:
    "bg-indigo-100 text-indigo-700 dark:bg-indigo-500/15 dark:text-indigo-300 ring-indigo-200 dark:ring-indigo-500/25",
  violet:
    "bg-violet-100 text-violet-700 dark:bg-violet-500/15 dark:text-violet-300 ring-violet-200 dark:ring-violet-500/25",
};

function ArticleCard({ article }: { article: Article }) {
  const [liked, setLiked] = useState(false);
  const [saved, setSaved] = useState(false);
  const [expanded, setExpanded] = useState(false);
  const [copied, setCopied] = useState(false);

  async function copyLink() {
    try {
      await navigator.clipboard.writeText(`https://read.example.com/p/${article.id}`);
      setCopied(true);
      window.setTimeout(() => setCopied(false), 1600);
    } catch {
      setCopied(false);
    }
  }

  return (
    <article className="group relative flex flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm transition-shadow duration-300 hover:shadow-lg sm:flex-row dark:border-zinc-800 dark:bg-zinc-900 dark:shadow-none dark:hover:shadow-[0_8px_40px_-12px_rgba(0,0,0,0.6)]">
      <div className="relative shrink-0 overflow-hidden sm:w-56 md:w-64">
        <div className="aspect-[16/10] h-full w-full sm:aspect-auto">
          {/* eslint-disable-next-line @next/next/no-img-element */}
          <img
            src={article.image}
            alt={`Illustration for ${article.title}`}
            loading="lazy"
            draggable={false}
            className={`h-full w-full object-cover transition-transform duration-500 ease-out group-hover:scale-[1.04] ${PREFIX}-kenburns`}
          />
        </div>
        <span
          className={`absolute left-3 top-3 inline-flex items-center rounded-full px-2.5 py-1 text-[11px] font-semibold ring-1 ${TINT[article.tint]}`}
        >
          {article.category}
        </span>
      </div>

      <div className="flex min-w-0 flex-1 flex-col gap-3 p-5">
        <div className="flex items-center gap-2 text-xs font-medium text-slate-500 dark:text-zinc-400">
          <span>{article.date}</span>
          <span aria-hidden="true" className="h-1 w-1 rounded-full bg-slate-300 dark:bg-zinc-600" />
          <span>{article.readTime}</span>
        </div>

        <h3 className="text-lg font-semibold leading-snug tracking-tight text-slate-900 dark:text-zinc-50">
          <a
            href={`#${article.id}`}
            className="rounded-sm outline-none after:absolute after:inset-0 after:content-[''] focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-zinc-900"
          >
            {article.title}
          </a>
        </h3>

        <p
          className={`text-sm leading-relaxed text-slate-600 dark:text-zinc-400 ${
            expanded ? "" : "line-clamp-2"
          }`}
        >
          {article.excerpt}
        </p>

        <button
          type="button"
          onClick={() => setExpanded((v) => !v)}
          aria-expanded={expanded}
          className="relative z-10 -mt-1 w-fit rounded text-xs font-semibold text-indigo-600 outline-none transition-colors hover:text-indigo-700 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 dark:text-indigo-400 dark:hover:text-indigo-300 dark:focus-visible:ring-offset-zinc-900"
        >
          {expanded ? "Show less" : "Read more"}
        </button>

        <div className="mt-auto flex items-center justify-between pt-2">
          <div className="flex min-w-0 items-center gap-2">
            <span className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-gradient-to-br from-indigo-500 to-violet-500 text-[11px] font-bold text-white">
              {article.author
                .split(" ")
                .map((n) => n[0])
                .join("")}
            </span>
            <span className="truncate text-xs font-medium text-slate-700 dark:text-zinc-300">
              {article.author}
            </span>
          </div>

          <div className="relative z-10 flex items-center gap-1">
            <button
              type="button"
              onClick={() => setLiked((v) => !v)}
              aria-pressed={liked}
              aria-label={liked ? "Remove like" : "Like this article"}
              className={`grid h-8 w-8 place-items-center rounded-full outline-none transition-colors focus-visible:ring-2 focus-visible:ring-rose-500 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-zinc-900 ${
                liked
                  ? "text-rose-500"
                  : "text-slate-400 hover:text-rose-500 dark:text-zinc-500 dark:hover:text-rose-400"
              }`}
            >
              <svg
                viewBox="0 0 24 24"
                className={`h-[18px] w-[18px] ${liked ? `${PREFIX}-pop` : ""}`}
                fill={liked ? "currentColor" : "none"}
                stroke="currentColor"
                strokeWidth="2"
              >
                <path
                  strokeLinecap="round"
                  strokeLinejoin="round"
                  d="M12 20.5l-1.5-1.35C5.4 14.6 2.5 11.9 2.5 8.6 2.5 6 4.5 4 7.1 4c1.5 0 2.9.7 3.9 1.8C12 4.7 13.4 4 14.9 4 17.5 4 19.5 6 19.5 8.6c0 3.3-2.9 6-8 10.55z"
                />
              </svg>
            </button>

            <button
              type="button"
              onClick={() => setSaved((v) => !v)}
              aria-pressed={saved}
              aria-label={saved ? "Remove from saved" : "Save article"}
              className={`grid h-8 w-8 place-items-center rounded-full outline-none transition-colors focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-zinc-900 ${
                saved
                  ? "text-indigo-600 dark:text-indigo-400"
                  : "text-slate-400 hover:text-indigo-600 dark:text-zinc-500 dark:hover:text-indigo-400"
              }`}
            >
              <svg
                viewBox="0 0 24 24"
                className="h-[18px] w-[18px]"
                fill={saved ? "currentColor" : "none"}
                stroke="currentColor"
                strokeWidth="2"
              >
                <path strokeLinecap="round" strokeLinejoin="round" d="M6 4h12v16l-6-4.2L6 20z" />
              </svg>
            </button>

            <button
              type="button"
              onClick={copyLink}
              aria-label={copied ? "Link copied" : "Copy link to article"}
              className="grid h-8 w-8 place-items-center rounded-full text-slate-400 outline-none transition-colors hover:text-slate-700 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 dark:text-zinc-500 dark:hover:text-zinc-200 dark:focus-visible:ring-offset-zinc-900"
            >
              {copied ? (
                <svg viewBox="0 0 24 24" className="h-[18px] w-[18px] text-emerald-500" fill="none" stroke="currentColor" strokeWidth="2.4">
                  <path strokeLinecap="round" strokeLinejoin="round" d="M5 12.5l4 4 10-10" />
                </svg>
              ) : (
                <svg viewBox="0 0 24 24" className="h-[18px] w-[18px]" fill="none" stroke="currentColor" strokeWidth="2">
                  <path strokeLinecap="round" strokeLinejoin="round" d="M9 9h9v11H9zM6 15H4V4h11v2" />
                </svg>
              )}
            </button>
          </div>
        </div>
      </div>
    </article>
  );
}

function ProductCard({ product }: { product: Product }) {
  const [inCart, setInCart] = useState(false);
  const [wished, setWished] = useState(false);
  const off = Math.round((1 - product.price / product.compareAt) * 100);

  return (
    <article className="group relative flex flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm transition-shadow duration-300 hover:shadow-lg sm:flex-row dark:border-zinc-800 dark:bg-zinc-900 dark:shadow-none dark:hover:shadow-[0_8px_40px_-12px_rgba(0,0,0,0.6)]">
      <div className="relative shrink-0 overflow-hidden bg-slate-50 sm:w-60 dark:bg-zinc-800/50">
        <div className="aspect-[4/3] h-full w-full sm:aspect-auto">
          {/* eslint-disable-next-line @next/next/no-img-element */}
          <img
            src={product.image}
            alt={`${product.brand} ${product.name}`}
            loading="lazy"
            draggable={false}
            className="h-full w-full object-cover transition-transform duration-500 ease-out group-hover:scale-[1.04]"
          />
        </div>
        <span className="absolute left-3 top-3 inline-flex items-center rounded-full bg-rose-500 px-2.5 py-1 text-[11px] font-bold text-white shadow-sm">
          -{off}%
        </span>
        <button
          type="button"
          onClick={() => setWished((v) => !v)}
          aria-pressed={wished}
          aria-label={wished ? "Remove from wishlist" : "Add to wishlist"}
          className={`absolute right-3 top-3 z-10 grid h-9 w-9 place-items-center rounded-full backdrop-blur outline-none transition focus-visible:ring-2 focus-visible:ring-rose-500 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-zinc-900 ${
            wished
              ? "bg-white text-rose-500 shadow-md dark:bg-zinc-900"
              : "bg-white/80 text-slate-500 hover:text-rose-500 dark:bg-zinc-900/70 dark:text-zinc-300"
          }`}
        >
          <svg
            viewBox="0 0 24 24"
            className={`h-[18px] w-[18px] ${wished ? `${PREFIX}-pop` : ""}`}
            fill={wished ? "currentColor" : "none"}
            stroke="currentColor"
            strokeWidth="2"
          >
            <path
              strokeLinecap="round"
              strokeLinejoin="round"
              d="M12 20.5l-1.5-1.35C5.4 14.6 2.5 11.9 2.5 8.6 2.5 6 4.5 4 7.1 4c1.5 0 2.9.7 3.9 1.8C12 4.7 13.4 4 14.9 4 17.5 4 19.5 6 19.5 8.6c0 3.3-2.9 6-8 10.55z"
            />
          </svg>
        </button>
      </div>

      <div className="flex min-w-0 flex-1 flex-col gap-2.5 p-5">
        <span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400 dark:text-zinc-500">
          {product.brand}
        </span>
        <h3 className="text-lg font-semibold leading-snug tracking-tight text-slate-900 dark:text-zinc-50">
          {product.name}
        </h3>

        <div className="flex items-center gap-2">
          <Stars rating={product.rating} />
          <span className="text-xs font-medium text-slate-500 dark:text-zinc-400">
            {product.rating} · {product.reviews.toLocaleString()} reviews
          </span>
        </div>

        <p className="text-sm leading-relaxed text-slate-600 dark:text-zinc-400">{product.blurb}</p>

        <div className="mt-auto flex flex-wrap items-center justify-between gap-3 pt-2">
          <div className="flex items-baseline gap-2">
            <span className="text-xl font-bold tracking-tight text-slate-900 dark:text-zinc-50">
              ${product.price}
            </span>
            <span className="text-sm font-medium text-slate-400 line-through dark:text-zinc-500">
              ${product.compareAt}
            </span>
          </div>

          <button
            type="button"
            onClick={() => setInCart((v) => !v)}
            aria-pressed={inCart}
            className={`inline-flex items-center gap-2 rounded-full px-4 py-2 text-sm font-semibold outline-none transition focus-visible:ring-2 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-zinc-900 ${
              inCart
                ? "bg-emerald-600 text-white focus-visible:ring-emerald-500"
                : "bg-slate-900 text-white hover:bg-slate-700 focus-visible:ring-slate-500 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-white"
            }`}
          >
            {inCart ? (
              <>
                <svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2.4">
                  <path strokeLinecap="round" strokeLinejoin="round" d="M5 12.5l4 4 10-10" />
                </svg>
                Added
              </>
            ) : (
              <>
                <svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2">
                  <path
                    strokeLinecap="round"
                    strokeLinejoin="round"
                    d="M6 6h15l-1.5 9h-12zM6 6L5 3H2m4 17.5a1 1 0 100-2 1 1 0 000 2zm11 0a1 1 0 100-2 1 1 0 000 2z"
                  />
                </svg>
                Add to cart
              </>
            )}
          </button>
        </div>
      </div>
    </article>
  );
}

function CompactCard({ article }: { article: Article }) {
  const [saved, setSaved] = useState(false);
  return (
    <article className="group relative flex items-stretch gap-4 rounded-xl border border-slate-200 bg-white p-3 shadow-sm transition-shadow hover:shadow-md dark:border-zinc-800 dark:bg-zinc-900 dark:shadow-none">
      <div className="relative h-20 w-20 shrink-0 overflow-hidden rounded-lg sm:h-24 sm:w-24">
        {/* eslint-disable-next-line @next/next/no-img-element */}
        <img
          src={article.image}
          alt={`Thumbnail for ${article.title}`}
          loading="lazy"
          draggable={false}
          className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
        />
      </div>
      <div className="flex min-w-0 flex-1 flex-col justify-center">
        <span className="text-[11px] font-semibold text-indigo-600 dark:text-indigo-400">
          {article.category}
        </span>
        <h3 className="mt-0.5 truncate text-sm font-semibold text-slate-900 dark:text-zinc-50">
          <a
            href={`#${article.id}-c`}
            className="rounded-sm outline-none after:absolute after:inset-0 after:content-[''] focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-zinc-900"
          >
            {article.title}
          </a>
        </h3>
        <span className="mt-1 text-xs text-slate-500 dark:text-zinc-400">{article.readTime}</span>
      </div>
      <button
        type="button"
        onClick={() => setSaved((v) => !v)}
        aria-pressed={saved}
        aria-label={saved ? "Remove from saved" : "Save article"}
        className={`relative z-10 grid h-8 w-8 shrink-0 place-items-center self-center rounded-full outline-none transition-colors focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-zinc-900 ${
          saved
            ? "text-indigo-600 dark:text-indigo-400"
            : "text-slate-400 hover:text-indigo-600 dark:text-zinc-500 dark:hover:text-indigo-400"
        }`}
      >
        <svg
          viewBox="0 0 24 24"
          className="h-[18px] w-[18px]"
          fill={saved ? "currentColor" : "none"}
          stroke="currentColor"
          strokeWidth="2"
        >
          <path strokeLinecap="round" strokeLinejoin="round" d="M6 4h12v16l-6-4.2L6 20z" />
        </svg>
      </button>
    </article>
  );
}

export default function CardHorizontal() {
  const reduce = useReducedMotion();

  return (
    <section className="relative w-full bg-slate-50 px-4 py-16 sm:px-6 sm:py-20 dark:bg-zinc-950">
      <style>{`
        @keyframes ${PREFIX}-pop {
          0% { transform: scale(1); }
          40% { transform: scale(1.28); }
          70% { transform: scale(0.92); }
          100% { transform: scale(1); }
        }
        .${PREFIX}-pop { animation: ${PREFIX}-pop 360ms cubic-bezier(0.34, 1.56, 0.64, 1); }
        @keyframes ${PREFIX}-kenburns {
          from { transform: scale(1); }
          to { transform: scale(1.04); }
        }
        @media (prefers-reduced-motion: reduce) {
          .${PREFIX}-pop { animation: none !important; }
          .${PREFIX}-kenburns { transition: none !important; }
        }
      `}</style>

      <div className="mx-auto max-w-3xl">
        <div className="mb-10 text-center">
          <span className="inline-flex items-center rounded-full bg-indigo-100 px-3 py-1 text-xs font-semibold text-indigo-700 ring-1 ring-indigo-200 dark:bg-indigo-500/15 dark:text-indigo-300 dark:ring-indigo-500/25">
            Horizontal cards
          </span>
          <h2 className="mt-4 text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl dark:text-zinc-50">
            Media beside the story
          </h2>
          <p className="mx-auto mt-3 max-w-xl text-sm leading-relaxed text-slate-600 dark:text-zinc-400">
            Image on the left, copy on the right. Every control is a real, focusable button with
            working state{reduce ? "" : " and tactile motion"}.
          </p>
        </div>

        <div className="flex flex-col gap-6">
          {ARTICLES.map((a) => (
            <ArticleCard key={a.id} article={a} />
          ))}

          <ProductCard product={PRODUCT} />

          <div className="mt-2 flex flex-col gap-3">
            <span className="text-xs font-semibold uppercase tracking-wider text-slate-400 dark:text-zinc-500">
              Compact list variant
            </span>
            {ARTICLES.map((a) => (
              <CompactCard key={`${a.id}-c`} article={a} />
            ))}
          </div>
        </div>
      </div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →