Vue componentFree

Board

A flexible board component built for smooth panning, zooming with a grid layout. It supports both mouse and touch input, making it a great fit for whiteboards, diagram tools, mind maps, and workflow editors. Designed for performance and responsiveness, it delivers a fluid experience on desktop and mobile with intuitive gestures and real-time interaction on a scalable canvas.

v1.0.0Updated last year

Install
Full screen ↗

Code

15 files in the repo
<template>
	<div
		v-show="!loading"
		class="board"
		@mousedown="startDrag"
		@wheel.prevent="onWheel"
		@touchstart="onTouchStart"
		@touchmove.prevent="onTouchMove"
		@touchend="onTouchEnd"
		@click.prevent.stop=""
		@dblclick.prevent.stop="resetView"
		ref="boardRef"
		:style="boardStyle"
	>
		<svg
			:width="width"
			:height="height"
			class="board-svg"
		>
			<g>
				<line
					v-for="xLine in verticalLines"
					:key="'v'+xLine"
					:x1="xLine"
					y1="0"
					:x2="xLine"
					:y2="height"
					:stroke="resolvedGridColor"
					stroke-width="1"
				/>
				<line
					v-for="yLine in horizontalLines"
					:key="'h'+yLine"
					x1="0"
					:y1="yLine"
					:x2="width"
					:y2="yLine"
					:stroke="resolvedGridColor"
					stroke-width="1"
				/>
			</g>
		</svg>
		<div
			class="board-content"
			:style="contentStyle"
		>
			<slot>
				<div style="user-select: none; transform:translateX(-50%) translateY(-50%)">Center</div>
			</slot>
		</div>
	</div>
</template>
<script>
	export default {
		name: 'Board',
		inject: {
			theme: {
				default: 'light'
			}
		},
		props: {
			zoom: {
				type: Number,
				default: 1,
				control: 'slider',
				min: 0,
				max: 1
			},
			x: {
				type: Number,
				default: 0,
				control: 'slider',
				min: -1000,
				max: 1000
			},
			y: {
				type: Number,
				default: 0,
				control: 'slider',
				min: -1000,
				max: 1000
			},
			width: {
				type: [Number, String],
				default: '100%',
				control: 'slider',
				unit: '%',
				min: 0,
				max: 100
			},
			height: {
				type: [Number, String],
				default: '100%',
				control: 'slider',
				unit: '%',
				min: 0,
				max: 100
			},
			backgroundColorLight: {
				type: String,
				default: '#f9f9f9',
				control: 'color'
			},
			gridColorLight: {
				type: String,
				default: '#e5e5e5',
				control: 'color'
			},
			colorLight: {
				type: String,
				default: '#242C33',
				control: 'color'
			},
			backgroundColorDark: {
				type: String,
				default: '#242C33',
				control: 'color'
			},
			gridColorDark: {
				type: String,
				default: '#070A0D',
				control: 'color'
			},
			colorDark: {
				type: String,
				default: '#f9f9f9',
				control: 'color'
			}
		},
		data() {
			return {
				scale: this.zoom,
				offsetX: this.x,
				offsetY: this.y,
				dragging: false,
				startX: 0,
				startY: 0,
				lastOffsetX: this.x,
				lastOffsetY: this.y,
				gridSize: 200,
				touchDistance: 0,
				touchCenter: {
					x: 0,
					y: 0
				},
				defaultZoom: this.zoom,
				defaultX: this.x,
				defaultY: this.y,
				currentWidth: this.width,
				currentHeight: this.height,
				loading: true
			};
		},
		computed: {
			resolvedBackground() {
				return this.theme === 'dark' ? this.backgroundColorDark : this.backgroundColorLight;
			},
			resolvedColor() {
				return this.theme === 'dark' ? this.colorDark : this.colorLight;
			},
			resolvedGridColor() {
				return this.theme === 'dark' ? this.gridColorDark : this.gridColorLight;
			},
			boardStyle() {
				return {
					backgroundColor: this.resolvedBackground,
					color: this.resolvedColor
				};
			},
			verticalLines() {
				const lines = [];
				const step = this.gridSize * this.scale;
				if (step < 1) return lines;
				const leftWorldX = -this.offsetX / this.scale;
				const rightWorldX = (this.currentWidth - this.offsetX) / this.scale;
				const startLine = Math.floor(leftWorldX / this.gridSize);
				const endLine = Math.ceil(rightWorldX / this.gridSize);
				for (let i = startLine; i <= endLine; i++) {
					const x = i * this.gridSize * this.scale + this.offsetX;
					if (x >= 0 && x <= this.currentWidth) {
						lines.push(x);
					}
				}
				return lines;
			},
			horizontalLines() {
				const lines = [];
				const step = this.gridSize * this.scale;
				if (step < 1) return lines;
				const topWorldY = -this.offsetY / this.scale;
				const bottomWorldY = (this.currentHeight - this.offsetY) / this.scale;
				const startLine = Math.floor(topWorldY / this.gridSize);
				const endLine = Math.ceil(bottomWorldY / this.gridSize);
				for (let i = startLine; i <= endLine; i++) {
					const y = i * this.gridSize * this.scale + this.offsetY;
					if (y >= 0 && y <= this.currentHeight) {
						lines.push(y);
					}
				}
				return lines;
			},
			contentStyle() {
				return {
					transform: `translate(${this.offsetX}px, ${this.offsetY}px) scale(${this.scale})`,
					transformOrigin: '0 0',
					position: 'absolute'
				};
			}
		},
		watch: {
			zoom(val) {
				this.scale = val;
			},
			x(val) {
				this.offsetX = this.currentWidth / 2 + val;
			},
			y(val) {
				this.offsetY = this.currentHeight / 2 + val;
			},
			offsetX(val) {
				if (this.loading) return;
				localStorage.setItem('pan-x', val);
			},
			offsetY(val) {
				if (this.loading) return;
				localStorage.setItem('pan-y', val);
			},
			scale(val) {
				if (this.loading) return;
				localStorage.setItem('scale', val);
			}
		},
		mounted() {
			window.addEventListener('mousemove', this.onDrag);
			window.addEventListener('mouseup', this.stopDrag);
			window.addEventListener('resize', this.onResize);
			this.onResize();
			this.scale = this.scale;
			this.offsetX = this.currentWidth / 2 + this.x;
			this.offsetY = this.currentHeight / 2 + this.y;
			this.$nextTick(() => {
				const x = localStorage.getItem('pan-x');
				const y = localStorage.getItem('pan-y');
				const scale = localStorage.getItem('scale');
				if (x) this.offsetX = Number(x);
				if (y) this.offsetY = Number(y);
				if (scale) this.scale = Number(scale);
				this.$emit('update:zoom', this.scale);
				this.$emit('update:x', this.offsetX);
				this.$emit('update:y', this.offsetY);
				this.loading = false;
			});
		},
		beforeUnmount() {
			window.removeEventListener('mousemove', this.onDrag);
			window.removeEventListener('mouseup', this.stopDrag);
			window.removeEventListener('resize', this.onResize);
		},
		methods: {
			startDrag(e) {
				this.dragging = true;
				this.startX = e.clientX;
				this.startY = e.clientY;
				this.lastOffsetX = this.offsetX;
				this.lastOffsetY = this.offsetY;
			},
			onDrag(e) {
				if (!this.dragging) return;
				const dx = e.clientX - this.startX;
				const dy = e.clientY - this.startY;
				this.offsetX = this.lastOffsetX + dx;
				this.offsetY = this.lastOffsetY + dy;
				this.$emit('update:x', this.offsetX);
				this.$emit('update:y', this.offsetY);
			},
			stopDrag() {
				this.dragging = false;
			},
			onWheel(e) {
				const mouseX = e.clientX;
				const mouseY = e.clientY;
				const worldX = (mouseX - this.offsetX) / this.scale;
				const worldY = (mouseY - this.offsetY) / this.scale;
				const zoomFactor = e.deltaY < 0 ? 1.1 : 0.9;
				this.scale *= zoomFactor;
				if (this.scale < 0.1) this.scale = 0.1;
				this.offsetX = mouseX - worldX * this.scale;
				this.offsetY = mouseY - worldY * this.scale;
				this.$emit('update:zoom', this.scale);
				this.$emit('update:x', this.offsetX);
				this.$emit('update:y', this.offsetY);
			},
			onTouchStart(e) {
				if (e.touches.length === 2) {
					this.touchDistance = this.getDistance(e.touches);
					this.touchCenter = this.getCenter(e.touches);
				} else if (e.touches.length === 1) {
					this.dragging = true;
					this.startX = e.touches[0].clientX;
					this.startY = e.touches[0].clientY;
					this.lastOffsetX = this.offsetX;
					this.lastOffsetY = this.offsetY;
				}
			},
			onTouchMove(e) {
				if (e.touches.length === 2) {
					const newDistance = this.getDistance(e.touches);
					const scaleFactor = newDistance / this.touchDistance;
					const newScale = this.scale * scaleFactor;
					if (newScale > 0.1) {
						const worldX = (this.touchCenter.x - this.offsetX) / this.scale;
						const worldY = (this.touchCenter.y - this.offsetY) / this.scale;
						this.scale = newScale;
						this.offsetX = this.touchCenter.x - worldX * this.scale;
						this.offsetY = this.touchCenter.y - worldY * this.scale;
						this.touchDistance = newDistance;
						this.$emit('update:zoom', this.scale);
						this.$emit('update:x', this.offsetX);
						this.$emit('update:y', this.offsetY);
					}
				} else if (this.dragging && e.touches.length === 1) {
					const dx = e.touches[0].clientX - this.startX;
					const dy = e.touches[0].clientY - this.startY;
					this.offsetX = this.lastOffsetX + dx;
					this.offsetY = this.lastOffsetY + dy;
					this.$emit('update:x', this.offsetX);
					this.$emit('update:y', this.offsetY);
				}
			},
			onTouchEnd() {
				this.dragging = false;
			},
			getDistance(touches) {
				const dx = touches[0].clientX - touches[1].clientX;
				const dy = touches[0].clientY - touches[1].clientY;
				return Math.sqrt(dx * dx + dy * dy);
			},
			getCenter(touches) {
				return {
					x: (touches[0].clientX + touches[1].clientX) / 2,
					y: (touches[0].clientY + touches[1].clientY) / 2
				};
			},
			centerOn(x, y) {
				const centerX = this.currentWidth / 2;
				const centerY = this.currentHeight / 2;
				this.offsetX = centerX - x * this.scale;
				this.offsetY = centerY - y * this.scale;
				this.$emit('update:x', this.offsetX);
				this.$emit('update:y', this.offsetY);
			},
			resetView() {
				this.scale = this.defaultZoom;
				this.offsetX = this.currentWidth / 2 + this.x;
				this.offsetY = this.currentHeight / 2 + this.y;
				this.$emit('update:zoom', this.scale);
				this.$emit('update:x', this.offsetX);
				this.$emit('update:y', this.offsetY);
			},
			onResize() {
				const centerWorldX = (this.currentWidth / 2 - this.offsetX) / this.scale;
				const centerWorldY = (this.currentHeight / 2 - this.offsetY) / this.scale;
				this.currentWidth = window.innerWidth;
				this.currentHeight = window.innerHeight;
				this.offsetX = this.currentWidth / 2 - centerWorldX * this.scale;
				this.offsetY = this.currentHeight / 2 - centerWorldY * this.scale;
				this.$emit('update:x', this.offsetX);
				this.$emit('update:y', this.offsetY);
			}
		}
	};

</script>
<style scoped>
	.board {
		position: fixed;
		top: 0;
		left: 0;
		width: 100%;
		height: 100%;
		overflow: hidden;
		cursor: grab;
		transition: all 0.1s linear;
	}

	.board:active {
		cursor: grabbing;
	}

	.board-svg {
		display: block;
		position: absolute;
		top: 0;
		left: 0;
	}

	.board-content {
		top: 0;
		left: 0;
	}

</style>

Props

PropTypeDefaultDescription
zoomNumber1—
xNumber0—
yNumber0—
widthNumber | String'100%'—
heightNumber | String'100%'—
backgroundColorLightString'#f9f9f9'—
gridColorLightString'#e5e5e5'—
colorLightString'#242C33'—
backgroundColorDarkString'#242C33'—
gridColorDarkString'#070A0D'—
colorDarkString'#f9f9f9'—
All data display →
Preview of Changelog TimelineFree

Component · Data display

Changelog Timeline

Vertical changelog timeline for Vue 3 with version badges, dated releases, typed change tags and filter chips. Built with Tailwind CSS, dark mode.

by Vueplay
Preview of Kanban Board$12

Component · Data display

Kanban Board

Vue 3 kanban board with native drag and drop, keyboard moves, WIP limits and inline add card. v-model board state, Tailwind CSS, dark mode.

by Vueplay
Preview of Stats CardsFree

Component · Data display

Stats Cards

Vue 3 KPI stat cards with trend deltas and tiny inline SVG sparklines, no chart library needed. Tailwind CSS, dark mode, typed props.

by Vueplay
Preview of Team GridFree

Component · Data display

Team Grid

Vue 3 team section with a responsive grid of member cards: initials avatars, role, short bio, location and social links. Tailwind CSS, dark mode.

by Vueplay

Pro feature

Upgrade to Pro

Upgrade to unlock more of Vueplay. Cancel any time.

  • Custom domains with HTTPS
  • Private projects
  • No "Made with Vueplay" badge
  • GitHub sync

$120/year

Compare plansBilling settings