Appearance
Spotlight Overlay Component Code
Dependencies
This component requires:
- Vue 3 with Composition API
- useSpotlightOverlay composable for state management
- Modern browsers with CSS clip-path support
Component Structure
The OSpotOverlay component consists of two main parts:
- OSpotOverlay.vue - The main component template and logic
- useSpotlightOverlay.js - The composable for state management and methods
Full Component Code
OSpotOverlay.vue
vue
<template>
<div class="spotlight-overlay-wrapper" v-show="showFloatingInstructionBar" @click="resetInstructionBarConfig">
<div ref="highlightAreaRef" class="highlight-area" :class="{ 'clip-path-animating': isClipPathAnimating }" :style="highlightArea">
<div class="highlight-area-border" :style="highlightAreaBorderStyles"></div>
</div>
<section tabindex="-1" class="floating-instruction-wrapper" :class="{'allow-background-interaction': barConfig.allowInteractionWithOtherElements}" :style="{...clipPathArea}" @blur="handleBlur">
<div
ref="floatingInstructionBar"
class="pointer-icon-wrapper arrow-svg"
:class="{
'top-center': arrowPosition === 'top',
'top-left': arrowPosition === 'topLeft',
'top-right': arrowPosition === 'topRight',
'top-extreme-right': arrowPosition === 'topExtremeRight',
'top-extreme-left': arrowPosition === 'topExtremeLeft',
'right-center': arrowPosition.startsWith('right'),
'left-center': arrowPosition.startsWith('left'),
'bottom-center': arrowPosition === 'bottom',
'bottom-left': arrowPosition === 'bottomLeft',
'bottom-right': arrowPosition === 'bottomRight',
'bottom-extreme-right': arrowPosition === 'bottomExtremeRight',
'bottom-extreme-left': arrowPosition === 'bottomExtremeLeft',
'animating': isAnimating,
'arrow-position-changed': arrowPosition !== previousArrowPosition
}"
:style="{
...positionProperty,
transition: isAnimating ? `all ${animationDuration}ms cubic-bezier(0.4, 0, 0.2, 1)` : 'none'
}"
@click.stop
>
<div class="instruction-bar-wrapper">
<svg
v-if="!barConfig.hideArrowIcon"
class="straight-pointer"
width="6"
height="36"
viewBox="0 0 6 36"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 0.333333C1.52724 0.333333 0.333333 1.52724 0.333333 3C0.333333 4.47276 1.52724 5.66667 3 5.66667C4.47276 5.66667 5.66667 4.47276 5.66667 3C5.66667 1.52724 4.47276 0.333333 3 0.333333ZM2.5 3L2.5 36L3.5 36L3.5 3L2.5 3Z"
fill="#0E0E0E"
/>
</svg>
<svg
v-if="!barConfig.hideArrowIcon"
class="turned-pointer"
width="17"
height="26"
viewBox="0 0 17 26"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M0.833333 3C0.833333 4.47276 2.02724 5.66667 3.5 5.66667C4.97276 5.66667 6.16667 4.47276 6.16667 3C6.16667 1.52724 4.97276 0.333335 3.5 0.333334C2.02724 0.333334 0.833333 1.52724 0.833333 3ZM15 3L15 3.5L15 3ZM16 4L15.5 4L16 4ZM3.5 3.5L15 3.5L15 2.5L3.5 2.5L3.5 3.5ZM15.5 4L15.5 25.5L16.5 25.5L16.5 4L15.5 4ZM15 3.5C15.2761 3.5 15.5 3.72386 15.5 4L16.5 4C16.5 3.17157 15.8284 2.5 15 2.5L15 3.5Z"
fill="#0E0E0E"
/>
</svg>
<div class="instruction-bar">
<div class="content-wrapper">
<div v-if="barConfig.title" class="message">{{ barConfig.title }}</div>
<div v-if="barConfig.button" class="cta-button" @click="barConfig.button.callback(barConfig)">{{ barConfig.button.label }}</div>
</div>
<div class="close-icon" @click="onCloseButtonClick">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="none">
<rect width="20" height="20" rx="3" fill="#F1F2F3"/>
<path d="M13.3346 13.3346L10.0013 10.0013M10.0013 10.0013L6.66797 6.66797M10.0013 10.0013L13.3347 6.66797M10.0013 10.0013L6.66797 13.3347" stroke="#3A393A" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
</div>
</div>
</div>
</section>
</div>
</template>
<script setup>
import { computed, watch, onMounted, nextTick, ref } from 'vue'
import useSpotlightOverlay from '../composables/useSpotlightOverlay'
// Use the spotlight overlay composable
const {
positionX,
positionY,
previousPositionX,
previousPositionY,
arrowPosition,
previousArrowPosition,
showFloatingInstructionBar,
barConfig,
highlightX,
highlightY,
clipPathX,
clipPathY,
clipPathHeight,
clipPathWidth,
highlightAreaBorderStyles,
isAnimating,
animationDuration,
resetInstructionBarConfig
} = useSpotlightOverlay()
// Template refs
const floatingInstructionBar = ref(null)
const highlightAreaRef = ref(null)
// Animation state
const isClipPathAnimating = ref(false)
// Watch for arrow position changes
watch(arrowPosition, (newPosition, oldPosition) => {
if (showFloatingInstructionBar.value && newPosition !== oldPosition) {
// Arrow position changed, smooth transition will be handled by CSS
nextTick(() => {
focusBar()
})
}
})
// Watch for clip path changes to trigger close-in animation
watch([clipPathX, clipPathY, clipPathWidth, clipPathHeight], ([newX, newY, newWidth, newHeight], [oldX, oldY, oldWidth, oldHeight]) => {
if (showFloatingInstructionBar.value &&
(oldX !== 0 || oldY !== 0 || oldWidth !== 0 || oldHeight !== 0) &&
(newX !== oldX || newY !== oldY || newWidth !== oldWidth || newHeight !== newHeight)) {
// Clip path changed, trigger close-in animation
triggerClipPathAnimation()
}
})
// Computed properties
const positionProperty = computed(() => {
const remSize = remToPixel.value
const minX = 0* remSize
const minY = 0 * remSize
let positionXValue = (Math.max(minX, positionX.value)) + "px"
let positionYValue = Math.max(minY, positionY.value) + "px"
if (barConfig.value.allowExtremeLeft) {
positionXValue = Math.max(20, positionX.value) + "px"
}
if (arrowPosition.value === "left") {
return {
left: `calc(${positionXValue} + 16px)`,
top: `calc(${positionYValue})`,
transform: "translate(100%, -50%)",
}
} else if (arrowPosition.value === "top") {
return {
left: `calc(${positionXValue})`,
top: `calc(${positionYValue} + 16px)`,
transform: "translate(-50%, 0)",
}
} else if (arrowPosition.value === "topRight") {
return {
left: `calc(${positionXValue})`,
top: `calc(${positionYValue} + 16px)`,
transform: "translate(-50%, 0)",
}
} else if (arrowPosition.value === "topExtremeRight") {
return {
left: `calc(${positionXValue})`,
top: `calc(${positionYValue} + 16px)`,
transform: "translate(-50%, 0)",
}
} else if (arrowPosition.value === "topLeft") {
return {
left: `calc(${positionXValue})`,
top: `calc(${positionYValue} + 16px)`,
transform: "translate(-50%, 0)",
}
} else if (arrowPosition.value === "topExtremeLeft") {
return {
left: `calc(${positionXValue})`,
top: `calc(${positionYValue} + 16px)`,
transform: "translate(-50%, 0)",
}
} else if (arrowPosition.value === "right") {
return {
left: `calc(${positionXValue} - 16px)`,
top: `calc(${positionYValue})`,
transform: "translate(-200%, -50%)",
}
} else if (arrowPosition.value === "bottom") {
return {
left: `calc(${positionXValue})`,
top: `calc(${positionYValue} - 24px)`,
transform: "translate(-50%, -50%)",
}
} else if (arrowPosition.value === "bottomRight") {
return {
left: `calc(${positionXValue})`,
top: `calc(${positionYValue} - 24px)`,
transform: "translate(-50%, -50%)",
}
} else if (arrowPosition.value === "bottomExtremeRight") {
return {
left: `calc(${positionXValue})`,
top: `calc(${positionYValue} - 16px)`,
transform: "translate(-50%, -50%)",
}
} else if (arrowPosition.value === "bottomLeft") {
return {
left: `calc(${positionXValue})`,
top: `calc(${positionYValue} - 24px)`,
transform: "translate(-50%, -50%)",
}
} else if (arrowPosition.value === "bottomExtremeLeft") {
return {
left: `calc(${positionXValue})`,
top: `calc(${positionYValue} - 16px)`,
transform: "translate(-50%, -50%)",
}
}
return {
left: `calc(${positionXValue})`,
top: `calc(${positionYValue})`,
}
})
const highlightArea = computed(() => {
return {
left: `${clipPathX.value}px`,
top: `${clipPathY.value}px`,
width: `${clipPathWidth.value}px`,
height: `${clipPathHeight.value}px`,
}
})
const clipPathArea = computed(() => {
if (barConfig.value.allowInteractionWithOtherElements) {
return {}
}
const rectTop = (clipPathY.value / window.innerHeight) * 100
const rectBottom = ((clipPathY.value + clipPathHeight.value) / window.innerHeight) * 100
const rectLeft = (clipPathX.value / window.innerWidth) * 100
const rectRight = ((clipPathX.value + clipPathWidth.value) / window.innerWidth) * 100
return {
"clip-path": `polygon(
0% 0%,
0% 100%,
${rectLeft}% 100%,
${rectLeft}% ${rectTop}%,
${rectRight}% ${rectTop}%,
${rectRight}% ${rectBottom}%,
${rectLeft}% ${rectBottom}%,
${rectLeft}% 100%,
100% 100%,
100% 0%
)`
}
})
// Methods
const calculatePosition = () => {
const element = document.getElementById(barConfig.value.elementId)
if (!element) return
const rect = element.getBoundingClientRect()
const remSize = remToPixel.value
const minX = 0 * remSize
const minY = 0 * remSize
const gap = remSize
// Ensure minimum gaps
positionX.value = Math.max(minX, rect.left + gap)
positionY.value = Math.max(minY, rect.top + gap)
}
const onCloseButtonClick = () => {
if (barConfig.value.closeButtonCallBack) {
barConfig.value.closeButtonCallBack()
}
resetInstructionBarConfig()
}
const onClickHighlight = () => {
if (barConfig.value.button && barConfig.value.button.callback) {
barConfig.value.button.callback(barConfig.value)
}
}
const handleBlur = ({ relatedTarget }) => {
let bar = floatingInstructionBar.value
if (bar && (bar.contains(relatedTarget) || bar.isEqualNode(relatedTarget))) {
return
}
if (barConfig.value.onBlur) {
barConfig.value.onBlur(barConfig.value)
resetInstructionBarConfig()
}
}
const focusBar = () => {
nextTick(() => {
// Focus the popup
const popup = floatingInstructionBar.value
if (popup) {
popup.focus()
}
})
}
const triggerClipPathAnimation = () => {
if (!highlightAreaRef.value) return
// Start animation
isClipPathAnimating.value = true
// Reset animation after duration
setTimeout(() => {
isClipPathAnimating.value = false
}, 600) // 600ms animation duration
}
// Lifecycle hooks
onMounted(() => {
calculatePosition()
})
</script>
<style lang="scss" scoped>
* {
box-sizing: border-box;
}
.spotlight-overlay-wrapper {
pointer-events: none;
box-sizing: border-box;
position: fixed;
width: 100%;
height: 100%;
top: 0px;
bottom: 0px;
left: 0px;
right: 0px;
.highlight-area {
width: 0;
height: 0;
pointer-events: none;
display: flex;
justify-content: center;
align-items: center;
background: transparent;
border: none;
background: transparent;
position: absolute;
transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1);
&.clip-path-animating {
animation: closeInAnimation 0.5s cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
.highlight-area-border {
display: flex;
border: 9999px solid rgba(35, 33, 33, 0.7);
width: 100%;
height: 100%;
min-width: 100%;
min-height: 100%;
box-sizing: content-box;
background: transparent;
transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1);
}
}
}
.floating-instruction-wrapper {
pointer-events: all;
box-sizing: border-box;
position: fixed;
width: 100%;
height: 100%;
top: 0px;
bottom: 0px;
left: 0px;
right: 0px;
}
.instruction-bar {
pointer-events: all;
display: grid;
grid-template-columns: 1fr max-content;
align-items: center;
gap: 0.5rem;
padding: 0.6875rem 0.5rem 0.625rem 0.75rem;
border-radius: 8px;
border: 1px solid gray;
background: #ffffff;
box-shadow: 3px 0px 0px 0px orange inset, 0px 6px 12px 0px rgba(0, 0, 0, 0.25);
width: 18rem;
// Smooth transitions for content changes
transition: opacity 0.2s ease-in-out;
.content-wrapper {
display: grid;
width: 100%;
align-items: center;
grid-template-columns: 1fr max-content;
gap: 0.125rem;
.message {
font-size: .75rem;
font-weight: 600;
color: black;
}
.cta-button {
display: flex;
padding: .5rem .625rem;
justify-content: center;
align-items: center;
gap: .625rem;
border-radius: 3px;
background: linear-gradient(90deg, rgba(255, 89, 0, 0.755) 0%, rgb(255, 140, 0) 100%);
color: #fefefe;
font-size: .6875rem;
font-weight: 600;
cursor: pointer;
box-shadow: 0px 1px 3px 0px rgba(0, 0, 0, 0.30);
}
}
.close-icon {
display: flex;
justify-content: center;
align-items: center;
width: 1rem;
height: 1rem;
cursor: pointer;
svg {
width: 1rem !important;
height: 1rem !important;
}
}
}
.pointer-icon-wrapper {
position: absolute;
&.animating {
will-change: transform, left, top;
}
&.arrow-position-changed {
.instruction-bar {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.straight-pointer,
.turned-pointer {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
}
.instruction-bar-wrapper {
position: relative;
.instruction-bar {
position: absolute;
}
}
&.arrow-svg {
&.top-center {
.instruction-bar, .celebration-gif-wrapper {
left: 50%;
top: 100%;
transform: translateX(-50%) translateY(0%);
}
}
&.top-right {
.instruction-bar, .celebration-gif-wrapper {
left: 50%;
top: 100%;
transform: translateX(calc(20px - 100%)) translateY(0%);
}
}
&.top-extreme-right {
.instruction-bar, .celebration-gif-wrapper {
left: 50%;
top: 100%;
transform: translateX(calc(10px - 100%)) translateY(0%);
}
svg {
transform: rotateY(180deg);
}
}
&.top-left {
.instruction-bar, .celebration-gif-wrapper {
left: 50%;
top: 100%;
transform: translateX(-20px) translateY(0%);
}
}
&.top-extreme-left {
.instruction-bar, .celebration-gif-wrapper {
left: 50%;
top: 100%;
transform: translateX(-10px) translateY(0%);
}
}
&.left-center {
.instruction-bar, .celebration-gif-wrapper {
left: 100%;
top: 50%;
transform: translateX(0%) translateY(-50%);
}
svg {
transform: rotate(-90deg);
}
}
&.right-center {
.instruction-bar, .celebration-gif-wrapper {
left: 0;
top: 50%;
transform: translateX(-100%) translateY(-50%);
}
svg {
transform: rotate(90deg);
}
}
&.bottom-center {
.instruction-bar, .celebration-gif-wrapper {
left: 50%;
top: 0%;
transform: translateX(-50%) translateY(-100%);
}
svg {
transform: rotate(180deg);
}
}
&.bottom-left {
.instruction-bar, .celebration-gif-wrapper {
left: 50%;
top: 0%;
transform: translateX(-20px) translateY(-100%);
}
svg {
transform: rotate(180deg);
}
}
&.bottom-extreme-left {
.instruction-bar, .celebration-gif-wrapper {
left: 50%;
top: 0%;
transform: translateX(-10px) translateY(-100%);
}
svg {
transform: rotateX(180deg);
}
}
&.bottom-right {
.instruction-bar, .celebration-gif-wrapper {
left: 50%;
top: 0%;
transform: translateX(calc(20px - 100%)) translateY(-100%);
}
svg {
transform: rotate(180deg);
}
}
&.bottom-extreme-right {
.instruction-bar, .celebration-gif-wrapper {
left: 0%;
top: 0%;
transform: translateX(calc(10px - 100%)) translateY(-100%);
}
svg {
transform: rotate(180deg);
}
}
}
.straight-pointer {
display: flex;
}
.turned-pointer {
display: none;
}
&.top-extreme-right,
&.top-extreme-left,
&.bottom-extreme-right,
&.bottom-extreme-left {
.straight-pointer {
display: none;
}
.turned-pointer {
display: flex;
}
}
}
@keyframes closeInAnimation {
0% {
transform: scale(2);
opacity: 1;
}
50% {
transform: scale(1.5);
opacity: 1;
}
100% {
transform: scale(1);
opacity: 1;
}
}
.allow-background-interaction {
pointer-events: none !important;
background: transparent !important;
}
// Animation keyframes for smooth movement
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
// Apply smooth entrance animation
.pointer-icon-wrapper {
animation: slideIn 0.3s ease-out;
.instruction-bar {
animation: fadeIn 0.2s ease-out;
}
}
// Enhanced transition for position changes
.pointer-icon-wrapper.animating {
transform-origin: center;
.instruction-bar {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
}
</style>useSpotlightOverlay.js
javascript
import { ref, reactive, computed } from 'vue'
// Initial state
const createInitialState = () => ({
positionX: -100000,
positionY: -100000,
previousPositionX: -100000,
previousPositionY: -100000,
arrowPosition: 'top',
previousArrowPosition: 'top',
showFloatingInstructionBar: false,
barConfig: {},
highlightX: -1,
highlightY: -1,
highlightRadius: 0,
clipPathX: 0,
clipPathY: 0,
clipPathHeight: 10000,
clipPathWidth: 10000,
highlightedElements: [],
showRenderReadyInstructionBar: false,
highlightAreaBorderStyles: {
borderRadius: '0',
borderWidth: '0',
},
isAnimating: false,
animationDuration: 300 // milliseconds
})
// Reactive state
const state = reactive(createInitialState())
// Utility functions
const calculateHighlightPosition = (rect) => {
const highlightX = rect.left + rect.width / 2
const highlightY = rect.top + rect.height / 2
const highlightRadius = Math.max(rect.width / 2, rect.height / 2)
return { highlightX, highlightY, highlightRadius }
}
const calculateClipPathPosition = (rect) => {
const clipPathX = rect.left
const clipPathY = rect.top
const clipPathHeight = rect.height
const clipPathWidth = rect.width
return { clipPathX, clipPathY, clipPathHeight, clipPathWidth }
}
const calculateArrowPosition = (rect, arrowPosition) => {
let positionX = rect.left + rect.width / 2
let positionY = rect.top + rect.height / 2
if (arrowPosition.startsWith('top')) {
positionY = rect.bottom
} else if (arrowPosition.startsWith('bottom')) {
positionY = rect.top
} else if (arrowPosition === 'right') {
positionX = rect.left
} else if (arrowPosition === 'left') {
positionX = rect.right
} else if (arrowPosition === 'leftTop') {
positionY = rect.top + 16
positionX = rect.right
} else if (arrowPosition === 'leftBottom') {
positionY = rect.bottom - 16
positionX = rect.right
} else if (arrowPosition === 'RightTop') {
positionY = rect.top + 16
positionX = rect.left
} else if (arrowPosition === 'RightBottom') {
positionY = rect.bottom - 16
positionX = rect.left
}
return { positionX, positionY }
}
// Actions
const setPosition = (data) => {
// Store previous position before updating
state.previousPositionX = state.positionX
state.previousPositionY = state.positionY
state.positionX = data.positionX
state.positionY = data.positionY
}
const setPositionWithAnimation = (data) => {
// Store previous position before updating
state.previousPositionX = state.positionX
state.previousPositionY = state.positionY
// Set animation flag
state.isAnimating = true
// Update position
state.positionX = data.positionX
state.positionY = data.positionY
// Clear animation flag after duration
setTimeout(() => {
state.isAnimating = false
}, state.animationDuration)
}
const setHighlight = (data) => {
state.highlightX = data.highlightX
state.highlightY = data.highlightY
state.highlightRadius = data.highlightRadius
}
const setClipPath = (data) => {
state.clipPathX = data.clipPathX
state.clipPathY = data.clipPathY
state.clipPathHeight = data.clipPathHeight
state.clipPathWidth = data.clipPathWidth
}
const addHighlightedElements = ({ elementId, className }) => {
// Add element to highlighted elements array
state.highlightedElements.push({ elementId, className })
// Apply the className to the element by elementId
const element = document.getElementById(elementId)
if (element) {
element.classList.add(className)
}
}
const resetHighlightedElements = () => {
// Reset the array and remove the class from all highlighted elements
state.highlightedElements.forEach(({ elementId, className }) => {
const element = document.getElementById(elementId)
if (element) {
element.classList.remove(className)
}
})
// Clear the highlighted elements array
state.highlightedElements = []
}
const setInstructionBarConfig = (data) => {
// Store previous arrow position
state.previousArrowPosition = state.arrowPosition
if (data.position) {
// Use animation if the bar is already visible and position is changing
if (state.showFloatingInstructionBar &&
(state.positionX !== data.position.positionX || state.positionY !== data.position.positionY)) {
setPositionWithAnimation(data.position)
} else {
setPosition(data.position)
}
}
if (data.clipPath) {
setClipPath(data.clipPath)
} else {
setClipPath({
clipPathX: 0,
clipPathY: 0,
clipPathHeight: 0,
clipPathWidth: 0,
})
}
if (data.highlight) {
setHighlight(data.highlight)
} else {
setHighlight({ highlightX: -1, highlightY: -1, highlightRadius: 0 })
}
state.arrowPosition = data.arrowPosition
state.showFloatingInstructionBar = data.isVisible
state.barConfig = { ...data.config, elementId: data.elementId }
}
const resetInstructionBarConfig = () => {
setPosition({
positionX: -100000,
positionY: -100000,
previousPositionX: -100000,
previousPositionY: -100000,
})
state.showFloatingInstructionBar = false
state.barConfig = {}
state.clipPathX = 0,
state.clipPathY = 0,
state.clipPathHeight = 10000,
state.clipPathWidth = 10000,
resetHighlightedElements()
}
const setHighlightAreaBorderStyles = (data) => {
state.highlightAreaBorderStyles = {
...createInitialState().highlightAreaBorderStyles,
...data
}
}
const setInstructionBarConfigByElementId = (data) => {
if (!data || !data.elementId) return
let payload = { ...data }
const element = document.getElementById(data.elementId)
if (!element) return
// Extract border styles from the element and set them in the state
const computedStyles = window.getComputedStyle(element)
const elementBorderRadius = computedStyles.getPropertyValue('border-radius')
let borderRadius = '0'
if(elementBorderRadius.includes('%')) {
borderRadius = `calc(${elementBorderRadius})`
} else {
borderRadius = `calc(${elementBorderRadius} + 9999px)`
}
setHighlightAreaBorderStyles({
borderRadius,
borderWidth: '9999px',
})
const rect = element.getBoundingClientRect()
const { highlightX, highlightY, highlightRadius } = calculateHighlightPosition(rect)
const { positionX, positionY } = calculateArrowPosition(rect, data.arrowPosition)
const { clipPathX, clipPathY, clipPathHeight, clipPathWidth } = calculateClipPathPosition(rect)
payload.position = { positionX, positionY }
payload.highlight = { highlightX, highlightY, highlightRadius }
payload.clipPath = { clipPathX, clipPathY, clipPathHeight, clipPathWidth }
highlightElement({ elementId: data.elementId, className: (data.elementHighlightClassName || 'tour-highlight') })
if (payload.config && payload.config.button) {
let buttonCallback = () => {}
if (payload.config.button.callback) {
buttonCallback = payload.config.button.callback
}
payload.config.button.callback = () => {
element.click()
buttonCallback()
resetInstructionBarConfig()
}
}
if (data.elementToHighLightId) {
resetHighlightedElements()
const elementToHighlight = document.getElementById(data.elementToHighLightId)
if (elementToHighlight) {
const highlightRect = elementToHighlight.getBoundingClientRect()
const { highlightX, highlightY, highlightRadius } = calculateHighlightPosition(highlightRect)
const { positionX, positionY } = calculateArrowPosition(highlightRect, data.arrowPosition)
payload.position = { positionX, positionY }
payload.highlight = { highlightX, highlightY, highlightRadius }
highlightElement({ elementId: data.elementToHighLightId, className: (data.elementHighlightClassName || 'tour-highlight') })
}
}
setInstructionBarConfig(payload)
}
const highlightElement = ({ elementId, className }) => {
addHighlightedElements({ elementId, className })
}
// Computed properties (getters)
const getters = {
positionX: computed(() => state.positionX),
positionY: computed(() => state.positionY),
previousPositionX: computed(() => state.previousPositionX),
previousPositionY: computed(() => state.previousPositionY),
arrowPosition: computed(() => state.arrowPosition),
previousArrowPosition: computed(() => state.previousArrowPosition),
showFloatingInstructionBar: computed(() => state.showFloatingInstructionBar),
barConfig: computed(() => state.barConfig),
highlightX: computed(() => state.highlightX),
highlightY: computed(() => state.highlightY),
highlightRadius: computed(() => state.highlightRadius),
clipPathX: computed(() => state.clipPathX),
clipPathY: computed(() => state.clipPathY),
clipPathHeight: computed(() => state.clipPathHeight),
clipPathWidth: computed(() => state.clipPathWidth),
highlightedElements: computed(() => state.highlightedElements),
showRenderReadyInstructionBar: computed(() => state.showRenderReadyInstructionBar),
highlightAreaBorderStyles: computed(() => state.highlightAreaBorderStyles),
isAnimating: computed(() => state.isAnimating),
animationDuration: computed(() => state.animationDuration)
}
// Main composable function
export default function useSpotlightOverlay() {
return {
// State
state,
// Getters
...getters,
// Actions
setInstructionBarConfig,
resetInstructionBarConfig,
setInstructionBarConfigByElementId,
highlightElement,
// Utility functions
setPosition,
setPositionWithAnimation,
setHighlight,
setClipPath,
addHighlightedElements,
resetHighlightedElements
}
}Key Implementation Details
Component Architecture
Template Structure:
spotlight-overlay-wrapper: Main container with overlay functionalityhighlight-area: The dimmed background with cutout for highlighted elementfloating-instruction-wrapper: Container for the instruction barpointer-icon-wrapper: Arrow and instruction bar positioning
State Management:
- Uses Vue 3 Composition API with reactive state
- Centralized state management through the composable
- Computed properties for derived state
Positioning System:
- Dynamic calculation based on target element's bounding rectangle
- Support for 12 different arrow positions
- Responsive positioning that adapts to screen boundaries
Animation System
CSS Transitions:
- Smooth position changes with cubic-bezier easing
- Clip path animations for highlight area changes
- Arrow position transitions
Keyframe Animations:
closeInAnimation: Scale effect for highlight areaslideIn: Entrance animation for instruction barfadeIn: Content fade effects
Accessibility Features
Focus Management:
- Proper focus handling for keyboard navigation
- Blur event handling for focus loss
- Tab index management
Keyboard Support:
- Full keyboard navigation support
- Escape key handling (can be implemented)
- Focus trapping within instruction bar
Performance Optimizations
Efficient Updates:
- Watchers only trigger when necessary
- Computed properties for derived state
- Minimal DOM manipulations
Memory Management:
- Proper cleanup of highlighted elements
- Event listener cleanup
- Animation timeout management
Browser Support
- Modern browsers with CSS clip-path support
- CSS Grid support required
- ES6+ JavaScript features
- Vue 3 Composition API
Dependencies
- Vue 3 with Composition API
- Modern CSS features (clip-path, CSS Grid, CSS Custom Properties)
- No external JavaScript dependencies