Components
Alert
A hand-drawn sketchy themed Alert component.
Code
tsx
395 lines
"use client" import { useEffect, useRef, useState } from "react" import rough from "roughjs" import { cn } from "@/lib/utils" type AlertVariant = "info" | "success" | "warning" | "error" | "note" type AlertStyle = "filled" | "outline" | "paper" type AlertShape = "rectangle" | "rounded-rectangle" type AlertBorderStyle = "rough" | "dashed" type AlertIcon = "info" | "check" | "warning" | "error" | "lightbulb" | "none" type RoughAlertOptions = { seed?: number stroke?: string strokeWidth?: number roughness?: number bowing?: number } type AlertProps = { children: React.ReactNode title?: string variant?: AlertVariant alertStyle?: AlertStyle shape?: AlertShape borderStyle?: AlertBorderStyle icon?: AlertIcon width?: number | string minHeight?: number | string radius?: number padding?: number borderColor?: string fillColor?: string transparent?: boolean dismissible?: boolean onDismiss?: () => void action?: React.ReactNode rotate?: number roughOptions?: RoughAlertOptions className?: string contentClassName?: string } const colors: Record<AlertVariant, string> = { info: "#bfdbfe", success: "#bbf7d0", warning: "#fde68a", error: "#fecaca", note: "#e9d5ff", } const seedMap: Record<AlertVariant, number> = { info: 101, success: 202, warning: 303, error: 404, note: 505, } 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 AlertIconSvg({ icon, }: { icon: AlertIcon }) { if (icon === "none") return null if (icon === "check") { return ( <svg viewBox="0 0 24 24" className="h-6 w-6" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" > <path d="M5 12.5 9.5 17 19 7" /> </svg> ) } if (icon === "warning") { return ( <svg viewBox="0 0 24 24" className="h-6 w-6" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" > <path d="M12 3 2.5 20h19L12 3Z" /> <path d="M12 9v5" /> <path d="M12 17.5h.01" /> </svg> ) } if (icon === "error") { return ( <svg viewBox="0 0 24 24" className="h-6 w-6" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" > <circle cx="12" cy="12" r="9" /> <path d="m9 9 6 6M15 9l-6 6" /> </svg> ) } if (icon === "lightbulb") { return ( <svg viewBox="0 0 24 24" className="h-6 w-6" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" > <path d="M9 18h6" /> <path d="M10 22h4" /> <path d="M8.5 14.5C7.55 13.6 7 12.35 7 11a5 5 0 0 1 10 0c0 1.35-.55 2.6-1.5 3.5-.65.6-1 1.2-1 2.5h-5c0-1.3-.35-1.9-1-2.5Z" /> </svg> ) } return ( <svg viewBox="0 0 24 24" className="h-6 w-6" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" > <circle cx="12" cy="12" r="9" /> <path d="M12 10v6" /> <path d="M12 7h.01" /> </svg> ) } export function Alert({ children, title, variant = "info", alertStyle = "filled", shape = "rounded-rectangle", borderStyle = "rough", icon, width = "100%", minHeight = 90, radius = 18, padding = 20, borderColor = "#111", fillColor, transparent = false, dismissible = false, onDismiss, action, rotate = 0, roughOptions, className, contentClassName, }: AlertProps) { const svgRef = useRef<SVGSVGElement | null>(null) const wrapperRef = useRef<HTMLDivElement | null>(null) const [visible, setVisible] = useState(true) const defaultIcon: Record<AlertVariant, AlertIcon> = { info: "info", success: "check", warning: "warning", error: "error", note: "lightbulb", } const resolvedIcon = icon ?? defaultIcon[variant] useEffect(() => { const svg = svgRef.current const wrapper = wrapperRef.current if (!svg || !wrapper || !visible) return const rect = wrapper.getBoundingClientRect() const drawWidth = rect.width const drawHeight = rect.height if (drawWidth <= 0 || drawHeight <= 0) return svg.replaceChildren() const rc = rough.svg(svg) const isOutline = alertStyle === "outline" const isPaper = alertStyle === "paper" const fill = transparent || isOutline ? undefined : fillColor ?? (isPaper ? "#fffbf2" : colors[variant]) const options = { seed: roughOptions?.seed ?? seedMap[variant], stroke: roughOptions?.stroke ?? borderColor, strokeWidth: roughOptions?.strokeWidth ?? 1.8, fill, fillStyle: fill ? isPaper ? "solid" : "solid" : undefined, roughness: roughOptions?.roughness ?? 1.5, bowing: roughOptions?.bowing ?? 0.9, strokeLineDash: borderStyle === "dashed" ? [8, 7] : undefined, } const x = 5 const y = 5 const w = drawWidth - 10 const h = drawHeight - 10 let node: SVGGElement if (shape === "rounded-rectangle") { node = rc.path( roundedRectPath(x, y, w, h, radius), options ) } else { node = rc.rectangle(x, y, w, h, options) } svg.appendChild(node) }, [ visible, variant, alertStyle, shape, borderStyle, radius, borderColor, fillColor, transparent, roughOptions, children, ]) if (!visible) return null const handleDismiss = () => { setVisible(false) onDismiss?.() } return ( <div ref={wrapperRef} role="alert" className={cn( "relative overflow-visible", className )} style={{ width, minHeight, transform: rotate ? `rotate(${rotate}deg)` : undefined, }} > <svg ref={svgRef} className="pointer-events-none absolute inset-0 h-full w-full overflow-visible" aria-hidden="true" /> <div className={cn( "relative z-10 flex items-start gap-4 text-black", contentClassName )} style={{ padding, }} > {resolvedIcon !== "none" && ( <div className="mt-0.5 shrink-0"> <AlertIconSvg icon={resolvedIcon} /> </div> )} <div className="min-w-0 flex-1"> {title && ( <h3 className="font-bold leading-tight"> {title} </h3> )} <div className={cn( "text-sm leading-relaxed", title && "mt-1" )} > {children} </div> {action && ( <div className="mt-3"> {action} </div> )} </div> {dismissible && ( <button type="button" onClick={handleDismiss} aria-label="Dismiss alert" className="shrink-0 text-xl font-bold leading-none transition-transform hover:rotate-6" > × </button> )} </div> </div> ) }
Usage
tsx
147 lines
type AlertItem = { id: number variant: "info" | "success" | "warning" | "error" title: string message: string } export function DemoAlert() { const [alerts, setAlerts] = useState<AlertItem[]>([]) const showAlert = ( variant: AlertItem["variant"], title: string, message: string ) => { const id = Date.now() setAlerts((prev) => [...prev, { id, variant, title, message }]) setTimeout(() => { setAlerts((prev) => prev.filter((alert) => alert.id !== id)) }, 5000) } const dismissAlert = (id: number) => { setAlerts((prev) => prev.filter((alert) => alert.id !== id)) } return ( <> <div className="flex flex-wrap items-center gap-3"> <Button onClick={() => showAlert( "info", "Information", "Here is some useful information." ) } type="submit" variant="#C9E0F9" width={120} height={36} shape="rounded-rectangle" className="font-family-hand cursor-pointer" roughOptions={{ roughness: 0.5, strokeWidth: 2, hachureGap: 3 }} > Info </Button> <Button onClick={() => showAlert( "success", "Success!", "Your changes have been saved." ) } type="submit" variant="green" width={120} height={36} shape="rounded-rectangle" className="font-family-hand cursor-pointer" roughOptions={{ roughness: 0.5, strokeWidth: 2, hachureGap: 3 }} > Success </Button> <Button onClick={() => showAlert( "warning", "Careful", "This action cannot be undone." ) } type="submit" variant="yellow" width={120} height={36} shape="rounded-rectangle" className="font-family-hand cursor-pointer" roughOptions={{ roughness: 0.5, strokeWidth: 2, hachureGap: 3 }} > Warning </Button> <Button onClick={() => showAlert( "error", "Something went wrong", "Please try again later." ) } type="submit" variant="#F77A7D" width={120} height={36} shape="rounded-rectangle" className="font-family-hand cursor-pointer" roughOptions={{ roughness: 0.5, strokeWidth: 2, hachureGap: 3 }} > Error </Button> </div> <div className="fixed bottom-6 right-6 z-50 flex w-[380px] max-w-[calc(100vw-2rem)] flex-col gap-3"> {alerts.map((alert) => ( <div key={alert.id} className="animate-in slide-in-from-right-5 duration-300 drop-shadow-[6px_6px_0px_rgba(0,0,0,0.25)]" > <Alert variant={alert.variant} title={alert.title} dismissible onDismiss={() => dismissAlert(alert.id)} className="font-family-hand" > {alert.message} </Alert> </div> ))} </div> </> ) }
Props
| Prop | Type | Default | Description |
|---|---|---|---|
children | React.ReactNode | - | Alert message content. |
title | string | - | Optional alert title. |
variant | AlertVariant | "info" | Controls the alert color theme. |
alertStyle | AlertStyle | "filled" | Controls the alert appearance. |
shape | AlertShape | "rounded-rectangle" | Controls the alert shape. |
borderStyle | AlertBorderStyle | "rough" | Controls the border style. |
icon | AlertIcon | Auto | Controls the alert icon. |
width | number | string | "100%" | Controls the alert width. |
minHeight | number | string | 90 | Minimum height of the alert. |
radius | number | 18 | Border radius of the alert. |
padding | number | 20 | Internal alert padding. |
borderColor | string | - | Overrides the default border color. |
fillColor | string | - | Overrides the default fill color. |
transparent | boolean | false | Makes the alert background transparent. |
dismissible | boolean | false | Displays a dismiss button. |
onDismiss | () => void | - | Callback triggered when the alert is dismissed. |
action | React.ReactNode | - | Optional action element displayed inside the alert. |
rotate | number | 0 | Rotates the alert in degrees. |
roughOptions | RoughAlertOptions | - | Custom RoughJS rendering configuration. |
className | string | - | Additional classes applied to the alert wrapper. |
contentClassName | string | - | Additional classes applied to the alert content. |
Rough Options
The roughOptions prop allows you to customize the hand-drawn appearance.
seed(number): Used to control the randomness of the stroke.stroke(string): Sets the color of the stroke.strokeWidth(number): Sets the width of the stroke.fill(string): Sets the fill color.fillStyle("solid","hachure","zigzag","cross-hatch","dots","dashed","zigzag-line"): Controls the filling style.hachureGap(number): Controls the gap between every hachure stroke.hachureAngle(number): Controls the hachure angle.roughness(number): Controls the roughness of the stroke.bowing(number): Controls the bending effect of the stroke.
Example
tsx
<Alert variant="error" title="Cross Hatch" icon="error" roughOptions={{ roughness: 2, strokeWidth: 2.5, fillStyle: "cross-hatch", hachureGap: 5, hachureAngle: 45, bowing: 1, }} > Something went wrong. </Alert>
Variants
- info
- success
- warning
- error
- note
Style
- paper
- outline
- filled