Horizontal Card
Original · freeA horizontal media card with image beside body copy.
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"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
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 quoteSimilar components
Browse all →
3D Tilt Cards
OriginalA responsive grid of cards that tilt in 3D toward your cursor with a light glare and lifted depth layers, powered by Framer Motion springs.

Spotlight Follow Cards
OriginalFeature cards where a soft radial spotlight and a glowing border chase the cursor on hover, with a staggered scroll reveal and an animated shimmer line.

3D Flip Cards
OriginalPricing-style cards that flip in 3D on hover or tap to reveal details on the back, keyboard accessible with a staggered entrance animation.

Animated Gradient Border Cards
OriginalCards wrapped in a live conic gradient border that rotates and speeds up on hover, with a shimmer sweep and a scrolling tag marquee.

Glow Effect Card
primitivesA reusable glow effect card for React and Tailwind, with hover and motion.

Neon Gradient Card
gradient-card.tsxA reusable neon gradient card for React and Tailwind, with hover and motion.

Spotlight Magic Card
card.tsxA reusable spotlight magic card for React and Tailwind, with hover and motion.

Tilt Spotlight Card
primitivesA reusable tilt spotlight card for React and Tailwind, with hover and motion.

Profile Card
OriginalA user profile card with avatar, stats and a follow toggle.

Product Card
OriginalA product card with image, price and add-to-cart feedback.

Pricing Single Card
OriginalA single pricing plan card with feature list and CTA.

Stat Card
OriginalA metric card with value, trend arrow and a small sparkline.

