Vue componentFree

Toolbox

A versatile toolbox component that supports up to eight fixed positions on the screen: top-left, top-center, top-right, middle-left, middle-right, and bottom zones. Each toolbox can be freely moved using drag-and-drop with mouse or touch gestures, while preventing overlap with existing toolboxes. Inside each toolbox, items can be reordered or moved between toolboxes, with positions and order saved for persistence. Perfect for customizable dashboards, design tools, or any interface requiring dynamic tool organization.

v1.0.0Updated last year

Install
Full screen ↗

Code

15 files in the repo
<template>
	<div
		ref="wrapper"
		class="wrapper"
	>
		<div
			v-bind="$attrs"
			class="toolbox"
			:style="{
				flexDirection: direction === 'vertical' ? 'column' : 'row',
				transition: ghost ? 'top 0.3s ease, left 0.3s ease' : 'none',
				opacity: mounted ? '1' : '0',
				background,
				color,
				borderColor,
				gap: gapTools + 'px',
				boxShadow: '0 3px 5px ' + (theme === 'dark' ? 'rgba(0, 0, 0, 0.5)' : 'rgba(0, 0, 0, 0.1)'),
				width: direction === 'vertical' ? size : 'auto',
				height: direction === 'vertical' ? 'auto' : size,
			}"
			ref="box"
		>
			<button
				class="drag-handle"
				:style="{
					width: direction === 'vertical' ? '100%' : 'auto',
					height: direction === 'vertical' ? 'auto' : '100%'
				}"
				@mousedown="startDrag"
				@touchstart="startDrag"
			>
				<svg
					viewBox="0 0 16 16"
					fill="currentColor"
					style="margin: auto; width: 50%; height: 50%;transition:all .3s ease"
					:style="{
						transform: visible ? 'rotate(0deg)' : 'rotate(405deg)'
					}"
				>
					<circle
						cx="4"
						cy="4"
						r="1.5"
					/>
					<circle
						cx="12"
						cy="4"
						r="1.5"
					/>
					<circle
						cx="4"
						cy="12"
						r="1.5"
					/>
					<circle
						cx="12"
						cy="12"
						r="1.5"
					/>
				</svg>
			</button>
			<div
				v-show="visible"
				ref="slotContainer"
				class="content toolbox-slot"
				:data-identifier="identifier"
				:style="{
					flexDirection: direction === 'vertical' ? 'column' : 'row', gap: gapTools + 'px'
				}"
			>
				<slot>
					<button style="aspect-ratio:1/1">A</button>
					<button style="aspect-ratio:1/1">B</button>
					<button style="aspect-ratio:1/1">C</button>
				</slot>
			</div>
			<Teleport
				v-if="ghost"
				to="body"
			>
				<div
					class="toolbox-ghost"
					:style="{
						top: ghost.y + 'px',
						left: ghost.x + 'px',
						width: size,
						height: size
					}"
				>
					<div
						class="toolbox-ghost-inner"
						:style="{
							background,
							color,
							borderColor,
							boxShadow: '0 3px 5px ' + (theme === 'dark' ? 'rgba(0, 0, 0, 0.5)' : 'rgba(0, 0, 0, 0.1)')
						}"
					>
						<svg
							style="width: 50%; height: 50%;"
							viewBox="0 0 16 16"
							fill="currentColor"
						>
							<circle
								cx="4"
								cy="4"
								r="1.5"
							/>
							<circle
								cx="12"
								cy="4"
								r="1.5"
							/>
							<circle
								cx="4"
								cy="12"
								r="1.5"
							/>
							<circle
								cx="12"
								cy="12"
								r="1.5"
							/>
						</svg>
					</div>
				</div>
			</Teleport>
		</div>
	</div>
</template>
<script>
	export default {
		name: 'ToolBox',
		inject: ['theme'],
		props: {
			gap: {
				type: Number,
				default: 20
			},
			gapTools: {
				type: Number,
				default: 5
			},
			size: {
				type: String,
				default: '40px',
				unit: 'px',
				control: 'slider'
			},
			startDirection: {
				type: String,
				default: 'horizontal',
				options: [{
					key: 'Horizontal',
					value: 'horizontal'
				}, {
					key: 'Vertical',
					value: 'vertical'
				}]
			},
			startPoint: {
				type: String,
				default: 'top-left',
				options: [{
					key: 'Top Left',
					value: 'top-left'
				}, {
					key: 'Top Center',
					value: 'top-center'
				}, {
					key: 'Top Right',
					value: 'top-right'
				}, {
					key: 'Middle Left',
					value: 'middle-left'
				}, {
					key: 'Middle Right',
					value: 'middle-right'
				}, {
					key: 'Bottom Left',
					value: 'bottom-left'
				}, {
					key: 'Bottom Center',
					value: 'bottom-center'
				}, {
					key: 'Bottom Right',
					value: 'bottom-right'
				}]
			},
			backgroundLight: {
				type: String,
				default: `#eeeeee50`,
				control: 'color'
			},
			colorLight: {
				type: String,
				default: `#333`,
				control: 'color'
			},
			borderColorLight: {
				type: String,
				default: `#55555530`,
				control: 'color'
			},
			backgroundDark: {
				type: String,
				default: `#22293150`,
				control: 'color'
			},
			colorDark: {
				type: String,
				default: `#eee`,
				control: 'color'
			},
			borderColorDark: {
				type: String,
				default: `#a0a0a030`,
				control: 'color'
			}
		},
		components: {},
		data() {
			return {
				ghost: null,
				points: [],
				currentSlug: null,
				direction: this.startDirection,
				mounted: false,
				takenSlugs: [],
				domId: null,
				childIds: [],
				dragging: false,
				visible: true,
				startX: 0,
				startY: 0
			};
		},
		computed: {
			availablePoints() {
				return this.points.filter(point => !this.takenSlugs.includes(point.slug));
			},
			identifier() {
				return this.domId || this.startPoint;
			},
			color() {
				return this.theme !== 'dark' ? this.colorLight : this.colorDark;
			},
			background() {
				return this.theme !== 'dark' ? this.backgroundLight : this.backgroundDark;
			},
			borderColor() {
				return this.theme !== 'dark' ? this.borderColorLight : this.borderColorDark;
			},
			gapPX() {
				return this.gap + 'px';
			}
		},
		watch: {
			currentSlug(val) {
				localStorage.setItem('toolbox-' + this.identifier, JSON.stringify({
					point: val,
					direction: this.direction,
					visible: this.visible
				}));
			},
			visible() {
				this.$nextTick(() => {
					this.snapToPoint(this.currentSlug);
				});
				localStorage.setItem('toolbox-' + this.identifier, JSON.stringify({
					point: this.currentSlug,
					direction: this.direction,
					visible: this.visible
				}));
			}
		},
		mounted() {
			this.domId = this.$el?.id || null;
			this.childIds = this.provideIdentifiers();
			let storedPoint = localStorage.getItem('toolbox-' + this.identifier);
			if (storedPoint) storedPoint = JSON.parse(storedPoint);
			if (storedPoint?.direction) this.direction = storedPoint.direction;
			if (storedPoint) this.visible = storedPoint.visible;
			this.calculatePoints();
			this.snapToPoint(storedPoint?.point || this.startPoint, true);
			this.mounted = true;
			window.addEventListener('resize', this.onResize);
			window.addEventListener('toolbox-place-children', this.handlePlaceChildren);
			window.addEventListener('toolbox-init', this.init);
			window.addEventListener('toolbox-snap', this.doSnap);
			this.$nextTick(() => {
				const event = new CustomEvent('toolbox-place-children', {
					detail: {
						children: this.childIds
					}
				});
				window.dispatchEvent(event);
				this.$nextTick(() => {
					this.snapToPoint(storedPoint?.point || this.startPoint, true);
					this.$nextTick(() => {
						this.updateTakenPoints();
						this.$nextTick(() => {
							this.snapToPoint(storedPoint?.point || this.startPoint, true);
						});
					});
				});
			});
		},
		beforeUnmount() {
			window.removeEventListener('resize', this.onResize);
			window.removeEventListener('toolbox-place-children', this.handlePlaceChildren);
			window.removeEventListener('toolbox-init', this.init);
			window.removeEventListener('toolbox-snap', this.doSnap);
		},
		methods: {
			init() {
				let index = 0;
				for (const id of this.childIds) {
					localStorage.setItem('toolbox-item-' + id, JSON.stringify({
						parent: this.identifier,
						index: index++
					}));
				}
				const event = new CustomEvent('toolbox-place-children', {
					detail: {
						children: this.childIds
					}
				});
				window.dispatchEvent(event);
			},
			doSnap() {
				let storedPoint = localStorage.getItem('toolbox-' + this.identifier);
				if (storedPoint) storedPoint = JSON.parse(storedPoint);
				if (storedPoint?.direction) this.direction = storedPoint.direction;
				if (storedPoint) this.visible = storedPoint.visible;
				this.snapToPoint(storedPoint?.point || this.startPoint, true);
			},
			onResize() {
				this.calculatePoints();
				this.snapToPoint(this.currentSlug);
			},
			calculatePoints() {
				const rect = this.$refs.wrapper.getBoundingClientRect();
				const {
					x,
					y,
					width,
					height
				} = rect;
				const cx = x + width / 2;
				const cy = y + height / 2;
				this.points = [{
					x: x,
					y: y,
					slug: 'top-left'
				}, {
					x: cx,
					y: y,
					slug: 'top-center'
				}, {
					x: x + width,
					y: y,
					slug: 'top-right'
				}, {
					x: x,
					y: cy,
					slug: 'middle-left'
				}, {
					x: x + width,
					y: cy,
					slug: 'middle-right'
				}, {
					x: x,
					y: y + height,
					slug: 'bottom-left'
				}, {
					x: cx,
					y: y + height,
					slug: 'bottom-center'
				}, {
					x: x + width,
					y: y + height,
					slug: 'bottom-right'
				}];
			},
			updateTakenPoints() {
				const allBoxes = document.querySelectorAll('.toolbox');
				this.takenSlugs = [];
				allBoxes.forEach(box => {
					if (box === this.$refs.box) return;
					const rect = box.getBoundingClientRect();
					const center = {
						x: rect.left + rect.width / 2,
						y: rect.top + rect.height / 2
					};
					let closest = this.points[0];
					let minDist = Infinity;
					for (const p of this.points) {
						const dist = Math.hypot(center.x - p.x, center.y - p.y);
						if (dist < minDist) {
							minDist = dist;
							closest = p;
						}
					}
					this.takenSlugs.push(closest.slug);
				});
			},
			provideIdentifiers() {
				const ids = [];
				const children = this.$refs.slotContainer?.children;
				if (!children) return;
				Array.from(children)
					.forEach((child, index) => {
						const id = `${this.identifier}-child-${index + 1}`;
						child.setAttribute('data-id', id);
						ids.push(id);
						const json = localStorage.getItem('toolbox-item-' + id);
						if (!json) {
							localStorage.setItem('toolbox-item-' + id, JSON.stringify({
								parent: this.identifier,
								index
							}));
						}
						this.addDragHandlers(child);
					});
				return ids;
			},
			handlePlaceChildren(e) {
				const container = this.$refs.slotContainer;
				const parent = container.getAttribute('data-identifier');
				if (!container) return;
				let idx = 0;
				for (const id of e.detail.children || []) {
					const json = localStorage.getItem('toolbox-item-' + id);
					if (!json) {
						continue;
					}
					const data = JSON.parse(json);
					if (data.parent !== this.identifier) continue;
					const element = document.querySelector(`[data-id="${id}"]`);
					if (!element) continue;
					let index = data.index;
					if (typeof index !== 'number' || index < 0 || index > container.children.length) {
						index = container.children.length;
					}
					const target = container.children[index] || null;
					container.insertBefore(element, target);
					idx++;
				}
				window.dispatchEvent(new CustomEvent('toolbox-snap'));
			},
			snapToPoint(slug = null, initial = false) {
				const direction = this.direction;
				const box = this.$refs.box;
				if (!box) return;
				const rect = box.getBoundingClientRect();
				let targetPoint;
				if (slug) {
					targetPoint = this.availablePoints.find(p => p.slug === slug);
				}
				if (!targetPoint) {
					const currentCenter = {
						x: rect.left + rect.width / 2,
						y: rect.top + rect.height / 2
					};
					let minDist = Infinity;
					for (const point of this.availablePoints) {
						const dist = Math.hypot(currentCenter.x - point.x, currentCenter.y - point.y);
						if (dist < minDist) {
							minDist = dist;
							targetPoint = point;
						}
					}
				}
				if (!targetPoint) return;
				this.currentSlug = targetPoint.slug;
				if (targetPoint.slug.startsWith('top')) {
					box.style.top = this.gap + 'px';
				}
				if (targetPoint.slug.startsWith('bottom')) {
					box.style.top = targetPoint.y - this.gap - rect.height + 'px';
				}
				if (targetPoint.slug.startsWith('middle')) {
					box.style.top = targetPoint.y - rect.height / 2 + 'px';
					this.direction = 'vertical';
				}
				if (targetPoint.slug.endsWith('left')) {
					box.style.left = this.gap + 'px';
				}
				if (targetPoint.slug.endsWith('right')) {
					box.style.left = targetPoint.x - this.gap - rect.width + 'px';
				}
				if (targetPoint.slug.endsWith('center')) {
					box.style.left = targetPoint.x - rect.width / 2 + 'px';
					this.direction = 'horizontal';
				}
				if (direction !== this.direction || initial) {
					this.$nextTick(() => {
						this.snapToPoint(slug);
						this.$nextTick(() => this.snapToPoint(slug));
					});
				}
			},
			startDrag(e) {
				e.preventDefault();
				this.updateTakenPoints();
				const startX = e.clientX || e.touches?.[0]?.clientX;
				const startY = e.clientY || e.touches?.[0]?.clientY - 40;
				this.startX = startX;
				this.startY = startY;
				this.ghost = {
					x: startX,
					y: startY
				};
				document.addEventListener('mousemove', this.onDrag);
				document.addEventListener('mouseup', this.stopDrag);
				document.addEventListener('touchmove', this.onDrag);
				document.addEventListener('touchend', this.stopDrag);
			},
			onDrag(e) {
				const x = e.clientX || e.touches?.[0]?.clientX;
				const y = e.clientY || e.touches?.[0]?.clientY - 40;
				this.ghost = {
					x,
					y
				};
				let closest = this.availablePoints[0];
				let minDist = Infinity;
				for (const point of this.availablePoints) {
					const dist = Math.hypot(x - point.x, y - point.y);
					if (dist < minDist) {
						minDist = dist;
						closest = point;
					}
				}
				if (Math.abs(x - this.startX) > 5 || Math.abs(y - this.startY) > 5) {
					this.dragging = true;
				}
				if (this.dragging && this.currentSlug !== closest.slug) {
					this.snapToPoint(closest.slug);
				}
			},
			stopDrag() {
				this.ghost = null;
				document.removeEventListener('mousemove', this.onDrag);
				document.removeEventListener('mouseup', this.stopDrag);
				document.removeEventListener('touchmove', this.onDrag);
				document.removeEventListener('touchend', this.stopDrag);
				if (!this.dragging) {
					this.visible = !this.visible;
				}
				this.dragging = false;
			},
			addDragHandlers(el) {
				let isDragging = false;
				let startX = 0;
				let startY = 0;
				let offsetX = 0;
				let offsetY = 0;
				let originalParent = null;
				let originalNextSibling = null;
				let placeholder = null;
				let ghostContainer = null;
				let currentSlot = null;
				let placeholderIndex = -1;
				el.style.touchAction = 'none';

				function createPlaceholder() {
					const ph = document.createElement('div');
					const style = getComputedStyle(el);
					ph.style.width = style.width;
					ph.style.height = style.height;
					ph.style.margin = style.margin;
					ph.style['flex-shrink'] = 0;
					ph.style['flex-grow'] = 0;
					ph.style.margin = style.margin;
					ph.style.pointerEvents = 'none';
					ph.style.opacity = '0';
					ph.style.background = 'transparent';
					ph.classList.add('drag-placeholder');
					return ph;
				}

				function createGhostContainer() {
					const gh = document.createElement('div');
					const style = getComputedStyle(el);
					gh.style.width = style.width;
					gh.style.height = style.height;
					gh.style.pointerEvents = 'none';
					return gh;
				}

				function getInsertIndex(container, x, y) {
					const children = Array.from(container.children)
						.filter(c => c !== placeholder && c !== el);
					const direction = container.style['flex-direction'];
					for (let i = 0; i < children.length; i++) {
						const child = children[i];
						const rect = child.getBoundingClientRect();
						if (direction === 'column') {
							if (y < rect.top + rect.height / 2) {
								return i;
							}
						} else {
							if (x < rect.left + rect.width / 2) {
								return i;
							}
						}
					}
					if (children.length > 0) {
						const lastRect = children[children.length - 1].getBoundingClientRect();
						if (direction === 'column' && y > lastRect.bottom) {
							return children.length;
						} else if (x > lastRect.right) {
							return children.length;
						}
					}
					return children.length;
				}
				const onPointerMove = e => {
					if (!isDragging) {
						const dx = e.clientX - startX;
						const dy = e.clientY - startY;
						if (Math.sqrt(dx * dx + dy * dy) < 5) return;
						isDragging = true;
						originalParent = el.parentNode;
						originalNextSibling = el.nextSibling;
						placeholder = createPlaceholder();
						ghostContainer = createGhostContainer();
						originalParent.insertBefore(placeholder, el);
						ghostContainer.appendChild(el);
						document.body.appendChild(ghostContainer);
						ghostContainer.style.position = 'fixed';
						ghostContainer.style.pointerEvents = 'none';
						ghostContainer.style.zIndex = 9999;
					}
					let extraOffsetY = 0;
					if (e.pointerType === 'touch') {
						extraOffsetY = 30;
					}
					ghostContainer.style.left = `${e.clientX - offsetX}px`;
					ghostContainer.style.top = `${e.clientY - offsetY - extraOffsetY}px`;
					const elemBelow = document.elementFromPoint(e.clientX, e.clientY);
					const slot = elemBelow?.closest('.toolbox-slot');
					if (slot && slot !== currentSlot) {
						currentSlot = slot;
					}
					if (currentSlot) {
						const correctedY = e.clientY - extraOffsetY;
						const newIndex = getInsertIndex(currentSlot, e.clientX, correctedY);
						if (!currentSlot.children.length || newIndex !== placeholderIndex) {
							const add = newIndex > placeholderIndex ? 1 : 0;
							placeholderIndex = newIndex;
							const refNode = currentSlot.children[newIndex + add] || null;
							currentSlot.insertBefore(placeholder, refNode);
						}
					}
				};
				const onPointerUp = e => {
					if (isDragging) {
						isDragging = false;
						let identifier = '';
						if (currentSlot) {
							identifier = currentSlot.getAttribute('data-identifier');
							currentSlot.insertBefore(el, currentSlot.children[placeholderIndex] || null);
						} else {
							if (originalParent) {
								originalParent.insertBefore(el, originalNextSibling);
							}
						}
						if (placeholder?.parentNode) {
							placeholder.parentNode.removeChild(placeholder);
						}
						if (ghostContainer?.parentNode) {
							ghostContainer.parentNode.removeChild(ghostContainer);
						}
						if (currentSlot && identifier) {
							Array.from(currentSlot.children)
								.forEach((child, index) => {
									const id = child.getAttribute('data-id');
									localStorage.setItem('toolbox-item-' + id, JSON.stringify({
										parent: identifier,
										index
									}));
								});
						}
						placeholder = null;
						currentSlot = null;
						placeholderIndex = -1;
						window.dispatchEvent(new CustomEvent('toolbox-snap'));
					}
					window.removeEventListener('pointermove', onPointerMove);
					window.removeEventListener('pointerup', onPointerUp);
				};
				el.addEventListener('pointerdown', e => {
					startX = e.clientX;
					startY = e.clientY;
					const rect = el.getBoundingClientRect();
					offsetX = e.clientX - rect.left;
					offsetY = e.clientY - rect.top;
					window.addEventListener('pointermove', onPointerMove);
					window.addEventListener('pointerup', onPointerUp);
				});
			}
		}
	};

</script>
<style scoped>
	* {
		--gap: v-bind(gapPX);
	}

	.toolbox-slot {
		min-height: 20px;
		min-width: 20px;
		user-select: none;
	}

	.toolbox {
		position: absolute;
		display: flex;
		border: 1px solid;
		box-sizing: border-box;
		border-radius: 6px;
		backdrop-filter: blur(10px);
		-webkit-backdrop-filter: blur(10px);
		user-select: none;
		max-width: calc(100vw - (var(--gap) * 2));
		max-height: calc(100vh - (var(--gap) * 2));
		overflow: auto;
		-ms-overflow-style: none;
		scrollbar-width: none;
	}

	.toolbox::-webkit-scrollbar {
		display: none;
	}

	.drag-handle {
		background: transparent;
		border: none;
		cursor: grab;
		aspect-ratio: 1/1;
	}

	.content {
		display: flex;
	}

	.wrapper {
		position: fixed;
		left: 0px;
		top: 0px;
		width: 100%;
		height: 100%;
		overflow: hidden;
		z-index: 1000;
		pointer-events: none;
	}

	.wrapper * {
		pointer-events: all;
	}

</style>
<style>
	.toolbox-ghost {
		position: fixed;
		pointer-events: none;
		transform: translate(-50%, -50%);
		z-index: 999999;
	}

	.toolbox-ghost-inner {
		display: flex;
		width: 100%;
		height: 100%;
		border: 1px solid;
		border-radius: 6px;
		justify-content: center;
		align-items: center;
	}

</style>

Props

PropTypeDefaultDescription
gapNumber20—
gapToolsNumber5—
sizeString'40px'—
startDirectionString'horizontal'—
startPointString'top-left'—
backgroundLightString`#eeeeee50`—
colorLightString`#333`—
borderColorLightString`#55555530`—
backgroundDarkString`#22293150`—
colorDarkString`#eee`—
borderColorDarkString`#a0a0a030`—
All navigation →
Preview of Command Palette$9

Component · Navigation

Command Palette

A ⌘K / Ctrl+K command palette for Vue 3 with fuzzy search, grouped results, recent items and full keyboard navigation. Tailwind CSS, dark mode.

by Vueplay
Preview of Dropdown MenuFree

Component · Navigation

Dropdown Menu

Accessible Vue 3 dropdown menu button with icons, separators, headings, disabled items, arrow-key navigation and click-outside close. Tailwind CSS.

by Vueplay
Preview of LinkFree

Component · Navigation

Link

A flexible link component that can act as a router-link for internal navigation or an href for external URLs. Can be used on its own or inside a Navigator.

Preview of Pagination BarFree

Component · Navigation

Pagination Bar

Vue 3 pagination bar with v-model:page, smart ellipsis, prev/next, a page-size picker, compact mobile view and a “Showing X–Y of Z” summary. Tailwind CSS.

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