Signature Pad
signature-pad ui Canvas-based digital signature capture with pointer events (mouse + touch). v-model emits a PNG data URL, with pen color/thickness, background, clear method, disabled/readonly states, and a live point count.
Also available for React ->Installation
$ pnpm dlx shadcn-vue@latest add https://uipkge.dev/r/vue/signature-pad.json $ npx shadcn-vue@latest add https://uipkge.dev/r/vue/signature-pad.json $ yarn dlx shadcn-vue@latest add https://uipkge.dev/r/vue/signature-pad.json $ bunx shadcn-vue@latest add https://uipkge.dev/r/vue/signature-pad.json Named registry:
npx shadcn-vue@latest add @uipkge/signature-pad Installs to: app/components/ui/signature-pad/ Examples
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
modelValue | string | null | null | optional |
width | number | 400 | optional |
height | number | 200 | optional |
penColor | string | '#0a0a0a' | optional |
penThickness | number | 2 | optional |
backgroundColor | string | '#ffffff' | optional |
exportFormat | string | 'image/png' | optional |
disabled | boolean | false | optional |
readonly | boolean | false | optional |
showClearButton | boolean | true | optional |
clearLabel | string | 'Clear' | optional |
class | HTMLAttributes['class'] | — | optional |
npm dependencies
Files installed (3)
-
app/components/ui/signature-pad/SignaturePad.vue 5.4 kB
<script setup lang="ts"> import type { HTMLAttributes } from 'vue' import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { Eraser } from 'lucide-vue-next' import { cn } from '@/lib/utils' import { signaturePadVariants } from './signature-pad.variants' interface Props { modelValue?: string | null width?: number height?: number penColor?: string penThickness?: number backgroundColor?: string exportFormat?: string disabled?: boolean readonly?: boolean showClearButton?: boolean clearLabel?: string class?: HTMLAttributes['class'] } const props = withDefaults(defineProps<Props>(), { modelValue: null, width: 400, height: 200, penColor: '#0a0a0a', penThickness: 2, backgroundColor: '#ffffff', exportFormat: 'image/png', disabled: false, readonly: false, showClearButton: true, clearLabel: 'Clear', }) const emit = defineEmits<{ 'update:modelValue': [value: string | null] begin: [] end: [] change: [value: string | null] }>() const canvasRef = ref<HTMLCanvasElement | null>(null) const isDrawing = ref(false) const pointCount = ref(0) const hasInk = ref(false) const ctx = ref<CanvasRenderingContext2D | null>(null) let lastX = 0 let lastY = 0 const isInteractive = computed(() => !props.disabled && !props.readonly) function setupCanvas() { const canvas = canvasRef.value if (!canvas) return const dpr = window.devicePixelRatio || 1 canvas.width = props.width * dpr canvas.height = props.height * dpr canvas.style.width = `${props.width}px` canvas.style.height = `${props.height}px` const context = canvas.getContext('2d') if (!context) return context.scale(dpr, dpr) context.lineCap = 'round' context.lineJoin = 'round' context.strokeStyle = props.penColor context.lineWidth = props.penThickness context.fillStyle = props.backgroundColor context.fillRect(0, 0, props.width, props.height) ctx.value = context pointCount.value = 0 hasInk.value = false } function getPointerPos(e: PointerEvent): { x: number; y: number } { const canvas = canvasRef.value if (!canvas) return { x: 0, y: 0 } const rect = canvas.getBoundingClientRect() return { x: e.clientX - rect.left, y: e.clientY - rect.top, } } function startDraw(e: PointerEvent) { if (!isInteractive.value || !ctx.value) return e.preventDefault() isDrawing.value = true const { x, y } = getPointerPos(e) lastX = x lastY = y ctx.value.beginPath() ctx.value.moveTo(x, y) pointCount.value += 1 hasInk.value = true emit('begin') canvasRef.value?.setPointerCapture(e.pointerId) } function draw(e: PointerEvent) { if (!isDrawing.value || !ctx.value) return e.preventDefault() const { x, y } = getPointerPos(e) ctx.value.beginPath() ctx.value.moveTo(lastX, lastY) ctx.value.lineTo(x, y) ctx.value.stroke() lastX = x lastY = y pointCount.value += 1 } function endDraw(e: PointerEvent) { if (!isDrawing.value) return isDrawing.value = false if (ctx.value) ctx.value.closePath() canvasRef.value?.releasePointerCapture(e.pointerId) exportSignature() emit('end') } function exportSignature() { const canvas = canvasRef.value if (!canvas) return if (!hasInk.value) { emit('update:modelValue', null) emit('change', null) return } const dataUrl = canvas.toDataURL(props.exportFormat) emit('update:modelValue', dataUrl) emit('change', dataUrl) } function clear() { const canvas = canvasRef.value const context = ctx.value if (!canvas || !context) return context.fillStyle = props.backgroundColor context.fillRect(0, 0, props.width, props.height) pointCount.value = 0 hasInk.value = false emit('update:modelValue', null) emit('change', null) } defineExpose({ clear, exportSignature, toDataURL: exportSignature, pointCount: computed(() => pointCount.value), isEmpty: computed(() => !hasInk.value), }) onMounted(() => { setupCanvas() }) onBeforeUnmount(() => { ctx.value = null }) watch( () => [props.penColor, props.penThickness, props.backgroundColor, props.width, props.height], () => { setupCanvas() }, ) </script> <template> <div data-uipkge data-slot="signature-pad" :data-disabled="disabled ? '' : undefined" :data-readonly="readonly ? '' : undefined" :class="cn(signaturePadVariants(), props.class)" > <canvas ref="canvasRef" :class="cn('block touch-none rounded-md', !isInteractive && 'pointer-events-none')" :style="{ touchAction: 'none' }" :aria-label="'Signature pad' + (disabled ? ' (disabled)' : '')" role="img" @pointerdown="startDraw" @pointermove="draw" @pointerup="endDraw" @pointercancel="endDraw" @pointerleave="endDraw" /> <div v-if="showClearButton && isInteractive" class="flex items-center justify-between gap-2 pt-2"> <span class="text-muted-foreground text-xs tabular-nums">{{ pointCount }} points</span> <button type="button" class="text-muted-foreground hover:text-foreground focus-visible:ring-ring inline-flex items-center gap-1 rounded-md text-xs transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50" :disabled="!hasInk" @click="clear" > <Eraser class="size-3.5" /> {{ clearLabel }} </button> </div> <slot name="actions" :clear="clear" :export="exportSignature" :empty="!hasInk" /> </div> </template> -
app/components/ui/signature-pad/signature-pad.variants.ts 0.3 kB
import type { VariantProps } from 'class-variance-authority' import { cva } from 'class-variance-authority' export const signaturePadVariants = cva( 'border-input bg-background inline-flex flex-col gap-1 rounded-lg border p-2 shadow-xs', ) export type SignaturePadVariants = VariantProps<typeof signaturePadVariants> -
app/components/ui/signature-pad/index.ts 0.1 kB
export { default as SignaturePad } from './SignaturePad.vue' export { signaturePadVariants, type SignaturePadVariants } from './signature-pad.variants'
Raw manifest: https://uipkge.dev/r/vue/signature-pad.json