diff --git a/resources/js/bootstrap/statamic.js b/resources/js/bootstrap/statamic.js
index fc0fd03c44..4bfd496b7f 100644
--- a/resources/js/bootstrap/statamic.js
+++ b/resources/js/bootstrap/statamic.js
@@ -7,8 +7,8 @@ import registerGlobalCommandPalette from './commands.js';
import registerUiComponents from './ui.js';
import registerFieldtypes from './fieldtypes.js';
import VueClickAway from 'vue3-click-away';
-import FloatingVue from 'floating-vue';
import 'floating-vue/dist/style.css';
+import tooltipDirective from '@/directives/tooltip.js';
import { createInertiaApp } from '@inertiajs/vue3';
import { router } from '@inertiajs/vue3';
import PortalVue from 'portal-vue';
@@ -244,7 +244,7 @@ export default {
this.$app.use(createPinia());
this.$app.use(PortalVue, { portalName: 'v-portal' });
this.$app.use(VueClickAway);
- this.$app.use(FloatingVue, { disposeTimeout: 30000, distance: 10 });
+ this.$app.directive('tooltip', tooltipDirective);
this.$app.use(VueComponentDebug, { enabled: import.meta.env.VITE_VUE_COMPONENT_DEBUG === 'true' });
toast.initialize(this.$app);
diff --git a/resources/js/components/Tooltips.vue b/resources/js/components/Tooltips.vue
new file mode 100644
index 0000000000..8a522fadf0
--- /dev/null
+++ b/resources/js/components/Tooltips.vue
@@ -0,0 +1,74 @@
+
+
+
+
+
+
+
+
+
+ {{ displayContent }}
+
+
+
+
+
diff --git a/resources/js/composables/tooltip.js b/resources/js/composables/tooltip.js
new file mode 100644
index 0000000000..2b5c1778da
--- /dev/null
+++ b/resources/js/composables/tooltip.js
@@ -0,0 +1,74 @@
+import { ref, shallowRef, readonly } from 'vue';
+
+const isVisible = ref(false);
+const content = ref('');
+const html = ref(false);
+const targetEl = shallowRef(null);
+
+let hideTimeout = null;
+let showTimeout = null;
+
+function setContent(el, options) {
+ targetEl.value = el;
+
+ if (typeof options === 'string') {
+ content.value = options;
+ html.value = false;
+ } else if (options && typeof options === 'object') {
+ content.value = options.content || '';
+ html.value = options.html || false;
+ } else {
+ content.value = '';
+ html.value = false;
+ }
+}
+
+function show(el, options) {
+ if (hideTimeout) {
+ clearTimeout(hideTimeout);
+ hideTimeout = null;
+ }
+
+ if (showTimeout) {
+ clearTimeout(showTimeout);
+ }
+
+ // If already visible, update immediately (for moving between adjacent elements)
+ if (isVisible.value) {
+ setContent(el, options);
+ return;
+ }
+
+ showTimeout = setTimeout(() => {
+ setContent(el, options);
+
+ if (content.value) {
+ isVisible.value = true;
+ }
+ }, 200);
+}
+
+function hide() {
+ if (showTimeout) {
+ clearTimeout(showTimeout);
+ showTimeout = null;
+ }
+
+ hideTimeout = setTimeout(() => {
+ isVisible.value = false;
+ targetEl.value = null;
+ content.value = '';
+ html.value = false;
+ }, 50);
+}
+
+export function useTooltip() {
+ return {
+ isVisible: readonly(isVisible),
+ content: readonly(content),
+ html: readonly(html),
+ targetEl: readonly(targetEl),
+ show,
+ hide,
+ };
+}
diff --git a/resources/js/directives/tooltip.js b/resources/js/directives/tooltip.js
new file mode 100644
index 0000000000..8ae42ce4e2
--- /dev/null
+++ b/resources/js/directives/tooltip.js
@@ -0,0 +1,49 @@
+import { useTooltip } from '@/composables/tooltip.js';
+
+const { show, hide } = useTooltip();
+
+function getOptions(binding) {
+ const value = binding.value;
+
+ if (value === null || value === undefined || value === false || value === '') {
+ return null;
+ }
+
+ return value;
+}
+
+function handleMouseEnter(el, binding) {
+ const options = getOptions(binding);
+ if (options) {
+ show(el, options);
+ }
+}
+
+function handleMouseLeave() {
+ hide();
+}
+
+export default {
+ mounted(el, binding) {
+ el._tooltipBinding = binding;
+ el._tooltipMouseEnter = () => handleMouseEnter(el, el._tooltipBinding);
+ el._tooltipMouseLeave = handleMouseLeave;
+
+ el.addEventListener('mouseenter', el._tooltipMouseEnter);
+ el.addEventListener('mouseleave', el._tooltipMouseLeave);
+ el.addEventListener('focus', el._tooltipMouseEnter);
+ el.addEventListener('blur', el._tooltipMouseLeave);
+ },
+
+ updated(el, binding) {
+ el._tooltipBinding = binding;
+ },
+
+ beforeUnmount(el) {
+ el.removeEventListener('mouseenter', el._tooltipMouseEnter);
+ el.removeEventListener('mouseleave', el._tooltipMouseLeave);
+ el.removeEventListener('focus', el._tooltipMouseEnter);
+ el.removeEventListener('blur', el._tooltipMouseLeave);
+ hide();
+ },
+};
diff --git a/resources/js/pages/layout/Layout.vue b/resources/js/pages/layout/Layout.vue
index e3a6e18b9d..865662d685 100644
--- a/resources/js/pages/layout/Layout.vue
+++ b/resources/js/pages/layout/Layout.vue
@@ -5,6 +5,7 @@ import { ConfigProvider } from 'reka-ui';
import SessionExpiry from '@/components/SessionExpiry.vue';
import LicensingAlert from '@/components/LicensingAlert.vue';
import PortalTargets from '@/components/portals/PortalTargets.vue';
+import Tooltips from '@/components/Tooltips.vue';
import { provide, watch, ref } from 'vue';
import useBodyClasses from './body-classes.js';
import useStatamicPageProps from '@/composables/page-props.js';
@@ -61,5 +62,6 @@ provide('layout', {
+