Components

SketchBorder

A hand-drawn border wrapper for sketch-style layouts.

Rounded Border

A softer rounded border for cards and sections.

Dashed Border

A dashed sketch border for notes or empty states.

Rough Stroke

Increase roughness and stroke width for a stronger sketch.

Code

tsx
181 lines
"use client"

import { useEffect, useRef } from "react"
import rough from "roughjs"
import { cn } from "@/lib/utils"

type SketchBorderShape = "rectangle" | "rounded-rectangle" | "circle" | "ellipse"

type SketchBorderStyle = "solid" | "dashed"

type RoughBorderOptions = {
    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
}

type SketchBorderProps = {
    children?: React.ReactNode
    width?: number | string
    height?: number | string
    minHeight?: number | string
    x?: number
    y?: number
    shape?: SketchBorderShape
    borderStyle?: SketchBorderStyle
    radius?: number
    borderColor?: string
    fillColor?: string
    transparent?: boolean
    padding?: number
    rotate?: number
    roughOptions?: RoughBorderOptions
    className?: string
    contentClassName?: string
}

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
  `
}

export function SketchBorder({
    children,
    width = "100%",
    height,
    minHeight = 120,
    x = 6,
    y = 6,
    shape = "rectangle",
    borderStyle = "solid",
    radius = 16,
    borderColor = "#111",
    fillColor = "transparent",
    transparent = true,
    padding = 16,
    rotate = 0,
    roughOptions,
    className,
    contentClassName,
}: SketchBorderProps) {
    const svgRef = useRef<SVGSVGElement | null>(null)
    const wrapperRef = useRef<HTMLDivElement | null>(null)

    useEffect(() => {
        const svg = svgRef.current
        const wrapper = wrapperRef.current
        if (!svg || !wrapper) return

        const rect = wrapper.getBoundingClientRect()
        const drawWidth = rect.width - x * 2
        const drawHeight = rect.height - y * 2

        if (drawWidth <= 0 || drawHeight <= 0) return

        svg.replaceChildren()

        const rc = rough.svg(svg)

        const options = {
            seed: roughOptions?.seed ?? 99,
            stroke: roughOptions?.stroke ?? borderColor,
            strokeWidth: roughOptions?.strokeWidth ?? 1.8,
            fill: transparent ? undefined : roughOptions?.fill ?? fillColor,
            fillStyle: transparent ? undefined : roughOptions?.fillStyle ?? "solid",
            hachureGap: roughOptions?.hachureGap ?? 7,
            hachureAngle: roughOptions?.hachureAngle ?? -10,
            roughness: roughOptions?.roughness ?? 1.6,
            bowing: roughOptions?.bowing ?? 0.9,
            strokeLineDash: borderStyle === "dashed" ? [8, 8] : undefined,
        }

        let node: SVGGElement

        if (shape === "circle") {
            const size = Math.min(drawWidth, drawHeight)
            node = rc.circle(x + drawWidth / 2, y + drawHeight / 2, size, options)
        } else if (shape === "ellipse") {
            node = rc.ellipse(
                x + drawWidth / 2,
                y + drawHeight / 2,
                drawWidth,
                drawHeight,
                options
            )
        } else if (shape === "rounded-rectangle") {
            node = rc.path(
                roundedRectPath(x, y, drawWidth, drawHeight, radius),
                options
            )
        } else {
            node = rc.rectangle(x, y, drawWidth, drawHeight, options)
        }

        svg.appendChild(node)
    }, [
        x,
        y,
        shape,
        borderStyle,
        radius,
        borderColor,
        fillColor,
        transparent,
        roughOptions,
        children,
    ])

    return (
        <div
            ref={wrapperRef}
            className={cn("relative", className)}
            style={{
                width,
                height,
                minHeight,
                transform: rotate ? `rotate(${rotate}deg)` : undefined,
            }}
        >
            <svg
                ref={svgRef}
                className="pointer-events-none absolute inset-0 h-full w-full"
                style={{ overflow: "visible" }}
                aria-hidden="true"
            />

            {children && (
                <div
                    className={cn("relative z-10", contentClassName)}
                    style={{ padding }}
                >
                    {children}
                </div>
            )}
        </div>
    )
}

Usage

tsx
<SketchBorder 
    borderStyle="dashed"
    minHeight={280}
    width={280}
    transparent={false}
    fillColor="#FDFBD4"
    roughOptions={{
        fillStyle: "zigzag"
    }}>
    <h3 className="text-lg font-semibold">Dashed Border</h3>
    <p className="mt-2 text-sm text-muted-foreground">
        A dashed sketch border for notes or empty states.
    </p>
</SketchBorder>

Props

PropTypeDefaultDescription
childrenReactNode-Content rendered inside the border container.
widthnumber | string"100%"Sets the width of the border container.
heightnumber | string-Sets a fixed height for the border container.
minHeightnumber120Sets the minimum height of the container.
xnumber6X offset used for RoughJS rendering.
ynumber6Y offset used for RoughJS rendering.
shapeSketchBorderShape"rectangle"Defines the shape of the border.
borderStyleSketchBorderStyle"solid"Controls the appearance of the border.
radiusnumber16Corner radius for rounded border shapes.
borderColorstring"#111"Border color of the sketch border.
fillColorstring"transparent"Fill color inside the border.
transparentbooleantrueRemoves any fill and renders only the border.
paddingnumber16Inner spacing of the content.
rotatenumber0Rotates the border container in degrees.
classNamestring-Additional classes applied to the wrapper.
contentClassNamestring-Additional classes applied to the content container.
roughOptionsRoughBorderOptions-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.
tsx
<SketchBorder
    transparent={false}
    fillColor="pink"
    minHeight={280}
    width={280}
    roughOptions={{
        fillStyle: "solid",
        strokeWidth: 2.4,
        roughness: 2.4,
        bowing: 1.2,
    }}
>
    <h3 className="text-lg font-semibold">Rough Stroke</h3>
    <p className="mt-2 text-sm text-muted-foreground">
        Increase roughness and stroke width for a stronger sketch.
    </p>
</SketchBorder>

Shapes

  • rectangle
  • rounded-rectangle
  • circle
  • ellipse

Border Styles

  • solid
  • dashed