Tilt Card
A card with 3D spring tilt, optional floating image, badge and radial shine on hover.
Installation
File Structure
Usage
import { TiltCard } from "@/components/unlumen-ui/tilt-card";
<TiltCard
title="Starter Kit"
description="Everything you need to ship fast."
price="Free"
badgeLabel="Popular"
imageSrc="/preview.png"
href="/templates/starter"
/>;Custom children
Content passed as children is rendered below the description — useful for feature lists, CTAs, or meta info.
<TiltCard
title="Pro"
price="$49"
badgeLabel="Best value"
badgeVariant="warning"
>
<ul className="text-sm text-muted-foreground space-y-1">
<li>✓ Unlimited projects</li>
<li>✓ Priority support</li>
</ul>
</TiltCard>API Reference
TiltCard
titlestring—Card heading.
description?string—Subtitle text.
price?string—Price label — shown as the left half of the split badge pill.
badgeLabel?string—Secondary badge label (right half of the split pill).
badgeVariant?"success" | "warning""success"Colour scheme for the right badge half.
imageSrc?string—Image source URL — rendered floating bottom-right.
imageAlt?string""Alt text for the image.
href?string—Wraps the card in an anchor tag.
tiltProps?Omit<TiltProps, "children" | "className">—Props forwarded to the inner Tilt wrapper.
Notes
- The card wraps everything in the
<Tilt>primitive withrotationFactor={11}by default. Override it viatiltProps={{ rotationFactor: 20 }}for more dramatic tilt. - When
hrefis provided, the entire card becomes an<a>tag. The anchor is framework-agnostic (plain HTML<a>, not Next.js<Link>) — wrap it yourself if you need client-side navigation. - The floating image is absolutely positioned at
top-27 -right-10with a slight rotation (-5deg). On hover, it shifts subtly (-rotate-3,-translate-y-1). Image dimensions are fixed atw-72(288px). ClippedCirclerenders a large white circle (circleSize={800}) that reveals on hover via CSS clip-path — it creates the radial shine effect.- The card has responsive height:
h-48on mobile,h-52onsm,h-56onmd. Override viaclassNameif you need a different size. - The split badge pill shows
priceon the left andbadgeLabelon the right with colored background based onbadgeVariant. If onlypriceis set (nobadgeLabel), it renders as a simple pill. - The component depends on two primitives:
Tilt(spring-based 3D rotation) andClippedCircle(hover reveal effect). Both are installed automatically via the registry.
Keep in mind
Most components on this site are inspired by or recreated from existing work across the web. I'm not here to take credit; just to learn, experiment, and sometimes push things a bit further. If something looks familiar and I forgot to mention you, reach out and I'll fix that right away.
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { ClippedCircle } from "@/components/unlumen-ui/clipped-circle";
import { Tilt, type TiltProps } from "@/components/unlumen-ui/tilt";
export interface TiltCardProps extends React.HTMLAttributes<HTMLDivElement> {
title: string;
description?: string;
/** left half of the split badge pill; shown as a simple pill if `badgeLabel` is omitted */
price?: string;
/** right half of the split pill, coloured by `badgeVariant` */
badgeLabel?: string;
badgeVariant?: "success" | "warning";
imageSrc?: string;
imageAlt?: string;
/** wraps the card in a plain `<a>` tag */
href?: string;
children?: React.ReactNode;
tiltProps?: Omit<TiltProps, "children" | "className">;
}
const BADGE_LABEL_CLASSES: Record<
NonNullable<TiltCardProps["badgeVariant"]>,
string
> = {
success: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300",
warning: "bg-amber-500/20 text-amber-700 dark:text-amber-300",
};
export function TiltCard({
title,
description,
price,
badgeLabel,
badgeVariant = "success",
imageSrc,
imageAlt = "",
href,
children,
tiltProps,
className,
...props
}: TiltCardProps) {
const inner = (
<Tilt
rotationFactor={11}
{...tiltProps}
className={cn(
"relative group overflow-hidden",
"bg-background border border-border rounded-lg",
"flex flex-col gap-4",
"h-48 sm:h-52 md:h-56 w-full",
"hover:shadow-lg hover:scale-105 transition-all duration-400 ease-out",
className,
)}
>
<div className="flex flex-row transition-all duration-200 justify-between px-4 sm:px-6 py-4 sm:py-5">
<div className="flex flex-col gap-1 flex-1 mr-2">
<h2 className="text-lg tracking-tight leading-tight font-medium">
{title}
</h2>
{description && (
<p className="text-foreground/50 text-sm">{description}</p>
)}
{children && <div className="mt-2">{children}</div>}
</div>
{price && badgeLabel ? (
<div className="inline-flex h-fit items-center text-sm whitespace-nowrap shrink-0">
<span className="rounded-l-full bg-secondary h-fit py-1 px-2 font-medium">
{price}
</span>
<span
className={cn(
"rounded-r-full text-sm h-fit py-1 px-2 font-medium",
BADGE_LABEL_CLASSES[badgeVariant],
)}
>
{badgeLabel}
</span>
</div>
) : price ? (
<span className="h-fit rounded-full bg-secondary px-3 py-1 text-sm font-medium whitespace-nowrap shrink-0">
{price}
</span>
) : null}
</div>
{imageSrc && (
<img
src={imageSrc}
alt={imageAlt}
width={288}
height={224}
loading="lazy"
decoding="async"
className={cn(
"absolute z-10 top-27 w-72 -right-10",
"rotate-[-5deg] border-border border rounded-md",
"transition-transform duration-300 ease-out",
"group-hover:-rotate-3 group-hover:-translate-y-1 group-hover:-translate-x-0.5",
)}
/>
)}
<ClippedCircle circleClassName="bg-white" circleSize={800} />
</Tilt>
);
if (href) {
return (
<a
href={href}
className="block cursor-pointer"
{...(props as React.AnchorHTMLAttributes<HTMLAnchorElement>)}
>
{inner}
</a>
);
}
return <div {...props}>{inner}</div>;
}