shadimage
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 · light
iconslucide-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"

More from knot-ui

Browse all components in this library →