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.
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
<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>| Prop | Type | Default | Description |
|---|---|---|---|
| gap | Number | 20 | — |
| gapTools | Number | 5 | — |
| size | String | '40px' | — |
| startDirection | String | 'horizontal' | — |
| startPoint | String | 'top-left' | — |
| backgroundLight | String | `#eeeeee50` | — |
| colorLight | String | `#333` | — |
| borderColorLight | String | `#55555530` | — |
| backgroundDark | String | `#22293150` | — |
| colorDark | String | `#eee` | — |
| borderColorDark | String | `#a0a0a030` | — |
Component · Navigation
A ⌘K / Ctrl+K command palette for Vue 3 with fuzzy search, grouped results, recent items and full keyboard navigation. Tailwind CSS, dark mode.
Component · Navigation
Accessible Vue 3 dropdown menu button with icons, separators, headings, disabled items, arrow-key navigation and click-outside close. Tailwind CSS.
Component · Navigation
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.
Component · Navigation
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.