Components
Buttton
A playful hand-drawn button component built for sketch-style interfaces.
Code
tsx
391 lines
"use client" import { useEffect, useRef, useState } from "react" import rough from "roughjs" import Link from "next/link" import { cn } from "@/lib/utils" type RoughButtonOptions = { seed?: number stroke?: string strokeWidth?: number fillStyle?: "solid" | "hachure" | "zigzag" | "cross-hatch" | "dots" | "dashed" | "zigzag-line" hachureGap?: number hoverHachureGap?: number hachureAngle?: number roughness?: number hoverRoughness?: number bowing?: number } type ButtonVariant = "yellow" | "purple" | "green" | (string & {}) type ButtonProps = { children: React.ReactNode href?: string external?: boolean onClick?: () => void variant?: ButtonVariant className?: string textClassName?: string roughOptions?: RoughButtonOptions width?: number height?: number x?: number y?: number fontSize?: number paddingX?: number fontWeight?: "normal" | "medium" | "semibold" | "bold" hoverSketch?: boolean hoverTranslateX?: number hoverTranslateY?: number hoverRotate?: number hoverScale?: number enable3D?: boolean depth?: number depthAngle?: number depthFill?: string depthHachureGap?: number depthHachureAngle?: number depthRoughness?: number depthStrokeWidth?: number type?: "button" | "submit" | "reset" disabled?: boolean shape?: "rectangle" | "rounded-rectangle" cornerRadius?: number } const presetColors: Record<string, string> = { yellow: "#fde047", purple: "#c084fc", green: "#86efac", } const presetSeeds: Record<string, number> = { yellow: 50, purple: 100, green: 150, } const fontWeightMap = { normal: 400, medium: 500, semibold: 600, bold: 700, } 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 polygonPath(points: [number, number][]) { return ( points .map(([px, py], index) => `${index === 0 ? "M" : "L"} ${px} ${py}`) .join(" ") + " Z" ) } function getSeedFromString(str: string): number { let hash = 0 for (let i = 0; i < str.length; i++) { hash = str.charCodeAt(i) + ((hash << 5) - hash) } return Math.abs(hash) % 1000 } export function Button({ children, href, external = false, onClick, variant = "yellow", className, textClassName, roughOptions, width = 180, height = 48, x = 5, y = 5, fontSize = 18, paddingX = 18, fontWeight = "bold", hoverSketch = true, hoverTranslateX = 0, hoverTranslateY = -4, hoverRotate = -1, hoverScale = 1, enable3D = false, depth = 12, depthAngle = 35, depthFill = "#fffbf2", depthHachureGap = 5, depthHachureAngle = 85, depthRoughness, depthStrokeWidth, type = "button", disabled = false, shape = "rectangle", cornerRadius = 12, }: ButtonProps) { const svgRef = useRef<SVGSVGElement | null>(null) const [isHovered, setIsHovered] = useState(false) const buttonColor = presetColors[variant] || variant const resolvedSeed = roughOptions?.seed ?? (presetSeeds[variant] || getSeedFromString(variant)) useEffect(() => { const svg = svgRef.current if (!svg) return svg.replaceChildren() const rc = rough.svg(svg) const normalHachureGap = roughOptions?.hachureGap ?? 5 const hoverHachureGap = roughOptions?.hoverHachureGap ?? 0.75 const normalRoughness = roughOptions?.roughness ?? 1.4 const hoverRoughness = roughOptions?.hoverRoughness ?? 1.9 const stroke = roughOptions?.stroke ?? "#111" const strokeWidth = roughOptions?.strokeWidth ?? 1.7 const roughness = hoverSketch && isHovered ? hoverRoughness : normalRoughness const hachureGap = hoverSketch && isHovered ? hoverHachureGap : normalHachureGap const angleRad = (depthAngle * Math.PI) / 180 const dx = enable3D ? Math.cos(angleRad) * depth : 0 const dy = enable3D ? Math.sin(angleRad) * depth : 0 const frontX = x const frontY = y const frontRight = frontX + width const frontBottom = frontY + height if (enable3D) { const rightFace = polygonPath([ [frontRight, frontY], [frontRight + dx, frontY + dy], [frontRight + dx, frontBottom + dy], [frontRight, frontBottom], ]) const bottomFace = polygonPath([ [frontX, frontBottom], [frontRight, frontBottom], [frontRight + dx, frontBottom + dy], [frontX + dx, frontBottom + dy], ]) const sideOptions = { seed: resolvedSeed + 10, stroke, strokeWidth: depthStrokeWidth ?? strokeWidth, fill: depthFill, fillStyle: "hachure" as const, hachureGap: depthHachureGap, hachureAngle: depthHachureAngle, roughness: depthRoughness ?? roughness + 0.35, bowing: roughOptions?.bowing ?? 0.8, } const bottomOptions = { seed: resolvedSeed + 20, stroke, strokeWidth: depthStrokeWidth ?? strokeWidth, fill: depthFill, fillStyle: "hachure" as const, hachureGap: depthHachureGap, hachureAngle: -35, roughness: depthRoughness ?? roughness + 0.35, bowing: roughOptions?.bowing ?? 0.8, } const rightShape = rc.path(rightFace, sideOptions) const bottomShape = rc.path(bottomFace, bottomOptions) svg.appendChild(rightShape) svg.appendChild(bottomShape) } const frontShape = shape === "rounded-rectangle" ? rc.path( roundedRectPath( frontX, frontY, width, height, cornerRadius ), { seed: resolvedSeed, stroke, strokeWidth, fill: buttonColor, fillStyle: roughOptions?.fillStyle ?? "hachure", hachureGap, hachureAngle: roughOptions?.hachureAngle ?? -10, roughness, bowing: roughOptions?.bowing ?? 0.8, } ) : rc.rectangle( frontX, frontY, width, height, { seed: resolvedSeed, stroke, strokeWidth, fill: buttonColor, fillStyle: roughOptions?.fillStyle ?? "hachure", hachureGap, hachureAngle: roughOptions?.hachureAngle ?? -10, roughness, bowing: roughOptions?.bowing ?? 0.8, } ) svg.appendChild(frontShape) }, [ variant, buttonColor, resolvedSeed, width, height, x, y, roughOptions, isHovered, hoverSketch, enable3D, depth, depthAngle, depthFill, depthHachureGap, depthHachureAngle, depthRoughness, depthStrokeWidth, shape, cornerRadius ]) const angleRad = (depthAngle * Math.PI) / 180 const depthX = enable3D ? Math.cos(angleRad) * depth : 0 const depthY = enable3D ? Math.sin(angleRad) * depth : 0 const totalWidth = width + x * 2 + Math.abs(depthX) + 4 const totalHeight = height + y * 2 + Math.abs(depthY) + 4 const hoverTransform = isHovered && !disabled ? `translate(${hoverTranslateX}px, ${hoverTranslateY}px) rotate(${hoverRotate}deg) scale(${hoverScale})` : "translate(0px, 0px) rotate(0deg) scale(1)" const inner = ( <span onMouseEnter={() => !disabled && setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} className={cn( "relative inline-flex items-center justify-center", "text-black transition-transform duration-150 ease-out", className )} style={{ width: totalWidth, height: totalHeight, transform: hoverTransform, }} > <svg ref={svgRef} viewBox={`0 0 ${totalWidth} ${totalHeight}`} className="absolute inset-0 h-full w-full" aria-hidden="true" /> <span className={cn( "relative z-10 flex items-center justify-center", textClassName )} style={{ width, height, fontSize, paddingLeft: paddingX, paddingRight: paddingX, fontWeight: fontWeightMap[fontWeight], }} > {children} </span> </span> ) if (href) { return ( <Link href={href} target={external ? "_blank" : undefined} rel={external ? "noopener noreferrer" : undefined} className="inline-block" > {inner} </Link> ) } return ( <button type={type} disabled={disabled} onClick={onClick} className="inline-block disabled:cursor-not-allowed disabled:opacity-70" > {inner} </button> ) }
Usage
tsx
<Button> Click Me </Button>
Props
| Prop | Type | Default | Description |
|---|---|---|---|
variant | ButtonVariant | "yellow" | Set color to the button. |
className | string | - | You can add any tailwind className here. |
width | number | 180 | Set width of the button |
height | number | 48 | Set height of the button |
shape | string | rectangle | Sets shape type to button |
cornerRadius | number | 12 | corner radious for the rounded-rectangle |
fontSize | number | 18 | Set the size of the font |
fontWeight | string | "bold" | Set the font size |
hoverSketch | boolean | true | Hover effect on sketch |
hoverTranslateX | number | 0 | Translate X axis on hover |
hoverTranslateY | number | -4 | Translate Y axis on hover |
hoverRotate | number | -1 | Rotate on hover |
hoverScale | number | 1 | Scale on hover |
enable3D | boolean | false | Set 3D effect on button |
depth | number | 12 | Depth of the 3D effect |
depthAngle | number | 35 | Angle of Depth |
depthFill | string | "#fffbf2" | Color of the depth |
depthRoughness | number | - | How much rough the depth will be |
depthStrokeWidth | number | - | The stroke width of depth |
disabled | boolean | false | Select the active type of button |
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.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.hoverHachureGap(number): On-hoverhachureGap.roughness(number): The amount of roughness, higher value means higher roughness.hoverRoughness(number): On-hoverroughness.bowing(number): The bending effect of the stroke.
tsx
<Button roughOptions={{ roughness: 2, bowing: 1, fillStyle: "hachure", hachureGap: 5, strokeWidth: 2, }} > Sketchy Button </Button>
Variants
- yellow
- purple
- green
- you can also add custom colors
Shape
- rectangle
- rounded-rectangle