knot-ui
componentflip-cardsource: ok · hit
Flip Card
A book-style flip card with a spine tab that reveals on hover.
npx shadcn add "https://knot-ui.trupal.me/r/flip-card.json"free
Interaction
scroll-reveal
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lighticonslucide-react
Registry dependencies
utils
Files1 · source cached 10/15/2026, 9:52:54 AM
components/library/flip-card.tsx
"use client"
import { BookOpenIcon, type LucideIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { forwardRef, type ReactNode, type KeyboardEvent } from "react"
type SizePreset = "sm" | "md" | "lg" | "xl"
interface SizeTokens {
/** Tailwind width class for the card */
width: string
/** Tailwind height class for the card */
height: string
/** Tailwind text-size class for the title */
titleText: string
/** Tailwind text-size class for the label */
labelText: string
/** Tailwind padding class for the inner content */
padding: string
/** Tailwind w/h classes for the label icon */
iconSize: string
/** Tailwind text class for the spine text */
spineTextSize: string
/** Tailwind gap for label row */
labelGap: string
/** Tailwind margin-bottom for label row */
labelMb: string
}
const SIZE_MAP: Record<SizePreset, SizeTokens> = {
sm: {
width: "w-48",
height: "h-64",
titleText: "text-xl",
labelText: "text-[11px]",
padding: "p-3",
iconSize: "w-3.5 h-3.5",
spineTextSize: "text-[10px]",
labelGap: "gap-1",
labelMb: "mb-2",
},
md: {
width: "w-60",
height: "h-80",
titleText: "text-2xl",
labelText: "text-xs",
padding: "p-4",
iconSize: "w-3.5 h-3.5",
spineTextSize: "text-xs",
labelGap: "gap-1.5",
labelMb: "mb-2.5",
},
lg: {
width: "w-72",
height: "h-100",
titleText: "text-3xl",
labelText: "text-xs",
padding: "p-5",
iconSize: "w-4 h-4",
spineTextSize: "text-sm",
labelGap: "gap-1.5",
labelMb: "mb-3",
},
xl: {
width: "w-80",
height: "h-[28rem]",
titleText: "text-4xl",
labelText: "text-xs",
padding: "p-6",
iconSize: "w-5 h-5",
spineTextSize: "text-sm",
labelGap: "gap-2",
labelMb: "mb-4",
},
}
interface FlipCardProps {
/** Label text shown above the title */
label?: string
/** Icon component rendered next to the label */
icon?: LucideIcon
/** Main title / heading content */
title?: ReactNode
/** Text on the spine tab revealed on hover */
spineText?: string
/** Accent color preset or custom Tailwind classes — presets: "red" | "blue" | "green" | "purple" | "orange" | "amber" | "teal" */
accentColor?: string
/** Additional className applied to the root container */
className?: string
/** Callback fired when the card is clicked */
onClick?: () => void
/**
* Size preset that controls card dimensions, text, padding, and spine proportionally.
* @default "lg"
*/
size?: SizePreset
/** Optional debug label shown on the shadow layer */
shadowLabel?: string
}
const ACCENT_BG: Record<string, string> = {
red: "bg-[#b1121f]",
blue: "bg-[#0077b6]",
green: "bg-green-600",
purple: "bg-[#59168b]",
orange: "bg-orange-600",
amber: "bg-amber-600",
teal: "bg-teal-600",
} as const
const ACCENT_TEXT: Record<string, string> = {
red: "text-red-700",
blue: "text-sky-700",
green: "text-green-700",
purple: "text-fuchsia-700",
orange: "text-orange-700",
amber: "text-amber-700",
teal: "text-teal-700",
} as const
const DEFAULT_TITLE = (
<>
Build a scalable Component Library that cut development time by{" "}
<span className="font-bold">50%</span> across Three Design Teams
</>
)
export const FlipCard = forwardRef<HTMLDivElement, FlipCardProps>(
(
{
label = "Case Study",
icon: Icon = BookOpenIcon,
title = DEFAULT_TITLE,
spineText = "click to read",
accentColor = "red",
className,
onClick,
size = "lg",
shadowLabel,
},
ref,
) => {
const tokens = SIZE_MAP[size]
const spineBg = ACCENT_BG[accentColor] ?? accentColor
const titleText = ACCENT_TEXT[accentColor] ?? accentColor
const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (onClick && (e.key === "Enter" || e.key === " ")) {
e.preventDefault()
onClick()
}
}
return (
<div
ref={ref}
className={cn(
"relative perspective-[1000px] group select-none",
onClick && "cursor-pointer",
className,
)}
onClick={onClick}
role={onClick ? "button" : undefined}
tabIndex={onClick ? 0 : undefined}
onKeyDown={handleKeyDown}
>
<div className="transform-3d">
{/* Shadow layer */}
<div className={cn("absolute bg-neutral-300 -z-1 rounded-lg h-full", tokens.width)}>
{shadowLabel && (
<p className="text-xs p-2 text-right text-neutral-500 whitespace-pre-line">
{shadowLabel}
</p>
)}
</div>
{/* Spine tab */}
<div
className={cn(
"absolute h-5/6 w-5/6 bottom-3 right-0 origin-[20%_100%] group-hover:rotate-z-6 rounded-md transition-transform duration-300 ease-out",
spineBg,
)}
>
<span className="absolute right-0 top-0 h-full flex items-start px-2 py-5">
<p className={cn(
"text-white [writing-mode:vertical-rl] rotate-180 uppercase font-semibold tracking-wider",
tokens.spineTextSize,
)}>
{spineText}
</p>
</span>
</div>
{/* Main card face */}
<div
className={cn(
"transition-transform duration-300 ease-out origin-left bg-neutral-200 relative group-hover:-rotate-y-35 rounded-lg shadow-sm overflow-hidden",
tokens.height,
tokens.width,
)}
>
<div className={tokens.padding}>
<span className={cn("flex items-center", tokens.labelGap, tokens.labelMb)}>
<Icon className={cn("text-neutral-500", tokens.iconSize)} />
<h2 className={cn(
"text-neutral-500 font-semibold uppercase tracking-wide",
tokens.labelText,
)}>
{label}
</h2>
</span>
<h1 className={cn("font-semibold leading-snug", tokens.titleText, titleText)}>
{title}
</h1>
</div>
</div>
</div>
</div>
)
},
)
FlipCard.displayName = "FlipCard"