Components
HoverCard
A rough sketch styled hover card, where the card gets rendered on hover, and supports a lot of customisation.
Code
tsx
425 lines
"use client" import { ReactNode, useEffect, useId, useRef, useState, } from "react" import rough from "roughjs" import { cn } from "@/lib/utils" type HoverCardVariant = "paper" | "white" | "yellow" | "purple" | "green" | "pink" | "blue" | "gray" | "orange" | "red" | "sky" type HoverCardPlacement = "top" | "bottom" | "left" | "right" | "top-start" | "top-end" | "bottom-start" | "bottom-end" type HoverCardShape = "rectangle" | "rounded-rectangle" type HoverCardBorderStyle = "solid" | "dashed" type RoughHoverCardOptions = { seed?: number stroke?: string strokeWidth?: number fill?: string fillStyle?: "solid" | "hachure" | "zigzag" | "cross-hatch" | "dots" | "dashed" | "zigzag-line" hachureGap?: number hachureAngle?: number roughness?: number bowing?: number dashLength?: number dashGap?: number } type HoverCardProps = { children: ReactNode content: ReactNode variant?: HoverCardVariant placement?: HoverCardPlacement shape?: HoverCardShape borderStyle?: HoverCardBorderStyle borderColor?: string fillColor?: string textColor?: string shadowColor?: string width?: number | string minWidth?: number | string maxWidth?: number | string padding?: number radius?: number offset?: number openDelay?: number closeDelay?: number disabled?: boolean showShadow?: boolean showArrow?: boolean arrowSize?: number rotate?: number activeRotate?: number activeScale?: number roughOptions?: RoughHoverCardOptions className?: string triggerClassName?: string contentClassName?: string } const colors: Record<HoverCardVariant, string> = { paper: "#fffbf2", white: "#ffffff", yellow: "#fef3c7", purple: "#d8c7ff", green: "#bbf7d0", pink: "#fbcfe8", blue: "#bfdbfe", gray: "#e5e7eb", orange: "#fed7aa", red: "#fecaca", sky: "#bae6fd", } const seedMap: Record<HoverCardVariant, number> = { paper: 501, white: 502, yellow: 503, purple: 504, green: 505, pink: 506, blue: 507, gray: 508, orange: 509, red: 510, sky: 511, } function roundedRectPath( x: number, y: number, width: number, height: number, radius: number ) { const r = Math.min(radius, width / 2, height / 2) return ` M ${x + r} ${y} L ${x + width - r} ${y} Q ${x + width} ${y} ${x + width} ${y + r} L ${x + width} ${y + height - r} Q ${x + width} ${y + height} ${x + width - r} ${y + height} L ${x + r} ${y + height} Q ${x} ${y + height} ${x} ${y + height - r} L ${x} ${y + r} Q ${x} ${y} ${x + r} ${y} Z ` } function getPlacementStyle( placement: HoverCardPlacement, offset: number ): { className: string transform: string origin: string } { if (placement === "top") { return { className: "left-1/2 bottom-full", transform: `translateX(-50%) translateY(-${offset}px)`, origin: "origin-bottom", } } if (placement === "bottom") { return { className: "left-1/2 top-full", transform: `translateX(-50%) translateY(${offset}px)`, origin: "origin-top", } } if (placement === "left") { return { className: "right-full top-1/2", transform: `translateX(-${offset}px) translateY(-50%)`, origin: "origin-right", } } if (placement === "right") { return { className: "left-full top-1/2", transform: `translateX(${offset}px) translateY(-50%)`, origin: "origin-left", } } if (placement === "top-start") { return { className: "left-0 bottom-full", transform: `translateY(-${offset}px)`, origin: "origin-bottom-left", } } if (placement === "top-end") { return { className: "right-0 bottom-full", transform: `translateY(-${offset}px)`, origin: "origin-bottom-right", } } if (placement === "bottom-start") { return { className: "left-0 top-full", transform: `translateY(${offset}px)`, origin: "origin-top-left", } } return { className: "right-0 top-full", transform: `translateY(${offset}px)`, origin: "origin-top-right", } } function getArrowClasses(placement: HoverCardPlacement) { if (placement.startsWith("top")) { return "left-1/2 top-full -translate-x-1/2 -translate-y-[2px] rotate-45" } if (placement.startsWith("bottom")) { return "left-1/2 bottom-full -translate-x-1/2 translate-y-[2px] rotate-45" } if (placement === "left") { return "left-full top-1/2 -translate-x-[2px] -translate-y-1/2 rotate-45" } return "right-full top-1/2 translate-x-[2px] -translate-y-1/2 rotate-45" } export function HoverCard({ children, content, variant = "paper", placement = "top", shape = "rounded-rectangle", borderStyle = "solid", borderColor = "#111", fillColor, textColor = "#111", shadowColor = "#111", width = 280, minWidth, maxWidth = 340, padding = 18, radius = 18, offset = 12, openDelay = 120, closeDelay = 120, disabled = false, showShadow = true, showArrow = false, arrowSize = 12, rotate = -1, activeRotate = 0, activeScale = 1, roughOptions, className, triggerClassName, contentClassName, }: HoverCardProps) { const id = useId() const svgRef = useRef<SVGSVGElement | null>(null) const cardRef = useRef<HTMLDivElement | null>(null) const openTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null) const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null) const [mounted, setMounted] = useState(false) const [open, setOpen] = useState(false) const finalFill = fillColor ?? colors[variant] const placementData = getPlacementStyle(placement, offset) function clearTimers() { if (openTimerRef.current) clearTimeout(openTimerRef.current) if (closeTimerRef.current) clearTimeout(closeTimerRef.current) } function showCard() { if (disabled) return clearTimers() openTimerRef.current = setTimeout(() => { setMounted(true) requestAnimationFrame(() => setOpen(true)) }, openDelay) } function hideCard() { clearTimers() closeTimerRef.current = setTimeout(() => { setOpen(false) closeTimerRef.current = setTimeout(() => { setMounted(false) }, 170) }, closeDelay) } useEffect(() => { return clearTimers }, []) useEffect(() => { const svg = svgRef.current const card = cardRef.current if (!svg || !card || !mounted) return svg.replaceChildren() const rect = card.getBoundingClientRect() const x = 5 const y = 5 const drawWidth = rect.width - x * 2 const drawHeight = rect.height - y * 2 if (drawWidth <= 0 || drawHeight <= 0) return const rc = rough.svg(svg) const options = { seed: roughOptions?.seed ?? seedMap[variant], stroke: roughOptions?.stroke ?? borderColor, strokeWidth: roughOptions?.strokeWidth ?? 1.8, fill: roughOptions?.fill ?? finalFill, fillStyle: roughOptions?.fillStyle ?? "solid", hachureGap: roughOptions?.hachureGap ?? 8, hachureAngle: roughOptions?.hachureAngle ?? -10, roughness: roughOptions?.roughness ?? 1.35, bowing: roughOptions?.bowing ?? 0.8, strokeLineDash: borderStyle === "dashed" ? [ roughOptions?.dashLength ?? 10, roughOptions?.dashGap ?? 8, ] : undefined, } const node = shape === "rounded-rectangle" ? rc.path( roundedRectPath(x, y, drawWidth, drawHeight, radius), options ) : rc.rectangle(x, y, drawWidth, drawHeight, options) svg.appendChild(node) }, [ mounted, open, content, variant, shape, borderStyle, borderColor, finalFill, radius, roughOptions, ]) return ( <span className={cn("relative inline-flex", className)} onMouseEnter={showCard} onMouseLeave={hideCard} onFocus={showCard} onBlur={hideCard} > <span tabIndex={0} aria-describedby={mounted ? id : undefined} className={cn("inline-flex outline-none", triggerClassName)} > {children} </span> {mounted && ( <div id={id} role="tooltip" ref={cardRef} className={cn( "absolute z-50 pointer-events-none", placementData.className, placementData.origin, "transition-[opacity,transform] duration-200 ease-out", open ? "opacity-100" : "opacity-0", contentClassName )} style={{ width, minWidth, maxWidth, transform: `${placementData.transform} rotate(${open ? activeRotate : rotate }deg) scale(${open ? activeScale : 0.96})`, filter: showShadow ? `drop-shadow(6px 6px 0 ${shadowColor})` : undefined, }} > <svg ref={svgRef} className="pointer-events-none absolute inset-0 h-full w-full overflow-visible" aria-hidden="true" /> {showArrow && ( <span className={cn( "absolute z-0 border-2", getArrowClasses(placement) )} style={{ width: arrowSize, height: arrowSize, backgroundColor: finalFill, borderColor, }} /> )} <div className="relative z-10 font-family-gaegu text-sm font-bold leading-relaxed" style={{ color: textColor, padding, }} > {content} </div> </div> )} </span> ) }
Usage
tsx
<HoverCard content="A simple sketch themed hover card." variant="paper" > <Button> Hover Me </Button> </HoverCard>
Props
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | - | Element that triggers the hover card. |
content | ReactNode | - | Content displayed inside the hover card. |
variant | HoverCardVariant | "paper" | Controls the hover card color theme. |
placement | HoverCardPlacement | "top" | Position of the hover card relative to the trigger. |
shape | HoverCardShape | "rounded-rectangle" | Defines the hover card shape. |
borderStyle | HoverCardBorderStyle | "solid" | Controls the border appearance. |
borderColor | string | "#111" | Border color of the hover card. |
fillColor | string | - | Overrides the default variant background color. |
textColor | string | "#111" | Color of the hover card content. |
shadowColor | string | "#111" | Color of the hover card shadow. |
width | number | 280 | Width of the hover card. |
minWidth | number | - | Minimum width of the hover card. |
maxWidth | number | 340 | Maximum width of the hover card. |
padding | number | 18 | Internal spacing of the hover card. |
radius | number | 18 | Corner radius for rounded shapes. |
offset | number | 12 | Distance between the trigger and hover card. |
openDelay | number | 120 | Delay before opening the hover card (ms). |
closeDelay | number | 120 | Delay before closing the hover card (ms). |
disabled | boolean | false | Disables the hover card interaction. |
showShadow | boolean | true | Displays the sketch-style shadow. |
showArrow | boolean | false | Displays a directional arrow. |
arrowSize | number | 12 | Size of the hover card arrow. |
rotate | number | -1 | Default rotation of the hover card. |
activeRotate | number | 0 | Rotation applied when the card is visible. |
activeScale | number | 1 | Scale applied when the card is visible. |
className | string | - | Additional classes applied to the hover card wrapper. |
triggerClassName | string | - | Additional classes applied to the trigger wrapper. |
contentClassName | string | - | Additional classes applied to the content container. |
roughOptions | RoughHoverCardOptions | - | Custom RoughJS rendering configuration. |
Rough Options
The roughOptions prop allows you to customize the hand-drawn appearance.
seed(number): Used to control the randomness of stroke.stroke(string): It gives the color to the stroke.strokeWidth(number): Sets width of stroke.fill(string): Stes the fill color.fillStyle: Gives the filling style in the component. ["solid","hachure","zigzag","cross-hatch","dots","dashed","zigzag-line"]hachureGap(number): The gap between every hachure stroke.hachureAngle(number): Sets the hachure angle.roughness(number): The amount of roughness, higher value means higher roughness.bowing(number): The bending effect of the stroke.dashLength(number): The length of every dash.dashGap(number): The gap between every dash.
tsx
<HoverCard placement="bottom" variant="yellow" borderStyle="dashed" content = { <div className="space-y-1"> <p className="font-black"> SketchUi </p> <p> Build playful hand-drawn interfaces with React. </p> </div> } roughOptions={{ fillStyle: "solid", roughness: 1, strokeWidth: 2, }} > <Button variant="yellow"> Project Info </Button> </HoverCard>
Variants
- paper
- white
- yellow
- purple
- green
- pink
- blue
- orange
- red
- sky
Shapes
- rectangle
- rounded-rectangle
Border Styles
- solid
- dashed
Placements
- top
- bottom
- left
- right
- top-start
- top-end
- bottom-start
- bottom-end