<div class="full"><span class="lbl">Direction-aware overlay</span>
<div class="da-grid">
<a href="#" class="da" data-direction><img src="https://images.unsplash.com/photo-1444703686981-a3abbc4d4fe3?auto=format&fit=crop&w=600&h=750&q=70" alt="A lone figure under the Milky Way on a starry night" width="600" height="750">
<span class="da-title" aria-hidden="true">Atacama Milky Way</span>
<span class="da-ov" data-da-overlay><b>Atacama Milky Way</b><span>5 nights · from $2,900</span><span>Two observatory visits, new-moon dates only.</span><i>View tour →</i></span></a>
<a href="#" class="da" data-direction><img src="https://images.unsplash.com/photo-1531366936337-7c912a4589a7?auto=format&fit=crop&w=600&h=750&q=70" alt="Green and violet aurora over a snowy landscape at night" width="600" height="750">
<span class="da-title" aria-hidden="true">Lapland Aurora Week</span>
<span class="da-ov" data-da-overlay><b>Lapland Aurora Week</b><span>6 nights · from $3,400</span><span>Glass cabins and a guide on call every night.</span><i>View tour →</i></span></a>
<a href="#" class="da" data-direction><img src="https://images.unsplash.com/photo-1519681393784-d120267933ba?auto=format&fit=crop&w=600&h=750&q=70" alt="Snowy mountain peaks under a sky full of stars" width="600" height="750">
<span class="da-title" aria-hidden="true">Alpine Star Trails</span>
<span class="da-ov" data-da-overlay><b>Alpine Star Trails</b><span>4 nights · from $1,750</span><span>Hut-to-hut walks with long-exposure workshops.</span><i>View tour →</i></span></a>
</div></div>
<div><span class="lbl">Cursor spotlight</span>
<a href="#" class="sp" data-spotlight><span class="sp-frame"><img src="https://images.unsplash.com/photo-1440404653325-ab127d49abc1?auto=format&fit=crop&w=900&h=675&q=70" alt="Vintage film reels, a projector and camera parts laid out on a table" width="900" height="675"></span>
<span class="sp-cap"><b>The Projection Room, 1952</b><span>Archive · object 0417 · explore the details</span></span></a></div>
<div><span class="lbl">Tilt that follows the pointer</span>
<a href="#" class="tl" data-tilt><span class="tl-frame"><img src="https://images.unsplash.com/photo-1505740420928-5e560c06d30e?auto=format&fit=crop&w=900&h=675&q=70" alt="Black over-ear headphones on a bright yellow background" width="900" height="675"></span>
<span class="tl-body"><b>Arc Wireless Headphones</b><span>40-hour battery · folds flat</span><span class="tl-price">$249</span></span></a></div>
<div class="full"><span class="lbl">Spotlight border</span>
<div class="sg" data-glow-group>
<a href="#" class="sg-card" data-glow><span class="sg-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9z"/></svg></span>
<b>Dark-sky sites</b><span>Every camp sits inside a dark-sky reserve, at least 60 km from city glow.</span></a>
<a href="#" class="sg-card" data-glow><span class="sg-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="9" cy="8" r="3.5"/><path d="M2.5 20a6.5 6.5 0 0 1 13 0M16 4.5a3.5 3.5 0 0 1 0 7M18 14a6 6 0 0 1 3.5 6"/></svg></span>
<b>Groups of eight</b><span>One guide for every eight guests, so a telescope is always free.</span></a>
<a href="#" class="sg-card" data-glow><span class="sg-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 8h3l2-3h6l2 3h3v11H4z"/><circle cx="12" cy="13" r="3.5"/></svg></span>
<b>Kit included</b><span>Tripods, red head-torches and a loan camera body if you need one.</span></a>
</div></div>
CSS
/* Direction-aware and cursor-follow effects. A few lines of JavaScript write the pointer position into
CSS custom properties; CSS does the drawing. Without the script, on touch, for keyboard focus and under
reduced motion every card still gets a complete, static hover state. */
/* 1. Direction-aware overlay: slides in from the edge the pointer entered, leaves by the edge it exits */
.da-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px}
.da{--da-ease:cubic-bezier(.2,.7,.2,1);position:relative;display:block;aspect-ratio:4/5;overflow:hidden;border-radius:14px;
background:#0b1024;color:#fff;text-decoration:none}
.da:focus-visible{outline:3px solid #6d28d9;outline-offset:4px}
.da img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}
.da-title{position:absolute;left:0;right:0;bottom:0;padding:48px 18px 16px;font-size:1.05rem;font-weight:700;line-height:1.25;
background:linear-gradient(to top,rgba(8,10,28,.9),rgba(8,10,28,0))}
.da-ov{position:absolute;inset:0;z-index:1;display:flex;flex-direction:column;justify-content:flex-end;gap:6px;padding:18px;
background:rgba(30,18,84,.92);line-height:1.35;opacity:0;transition:opacity .3s}
.da-ov b{font-size:1.15rem}
.da-ov span{font-size:.9rem;color:#ddd6fe}
.da-ov i{font-style:normal;font-weight:600;font-size:.88rem;margin-top:8px}
.da:is(:hover,:focus-visible) .da-ov{opacity:1}
.da-title{transition:opacity .3s}
.da:is(:hover,:focus-visible) .da-title,.da[data-da=in] .da-title{opacity:0}
.da[data-da] .da-ov{opacity:1;transition:none}
.da[data-da=in] .da-ov{animation:da-in .38s var(--da-ease) both}
.da[data-da=out] .da-ov{animation:da-out .38s var(--da-ease) both}
.da:focus-visible .da-ov{opacity:1;animation:none;transform:none}
@keyframes da-in{from{transform:translate(var(--da-x,0),var(--da-y,100%))}to{transform:none}}
@keyframes da-out{from{transform:none}to{transform:translate(var(--da-x,0),var(--da-y,100%))}}
/* 2. Cursor spotlight: the photo darkens around a circle that follows the pointer */
.sp{--x:var(--px,50%);--y:var(--py,50%);--sp-r:120px;display:block;border-radius:16px;color:#121528;text-decoration:none}
.sp:focus-visible{outline:3px solid #6d28d9;outline-offset:5px;--x:50%;--y:50%;--sp-r:170px}
.sp-frame{position:relative;display:block;aspect-ratio:4/3;overflow:hidden;border-radius:16px;background:#1a1a22}
.sp-frame img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}
.sp-frame::after{content:"";position:absolute;inset:0;opacity:0;transition:opacity .35s;
background:radial-gradient(circle var(--sp-r) at var(--x) var(--y),rgba(9,10,20,0) 55%,rgba(9,10,20,.84))}
.sp:is(:hover,:focus-visible) .sp-frame::after{opacity:1}
.sp-cap,.tl-body{display:grid;gap:2px;padding-top:14px;line-height:1.4}
.sp-cap b,.tl-body b{font-size:1.05rem}
.sp-cap span,.tl-body span{font-size:.88rem;color:#4b5270}
/* 3. Tilt that follows the pointer, with a glare spot */
.tl{--rx:0deg;--ry:0deg;display:block;padding:16px;border-radius:20px;background:#fff;color:#121528;text-decoration:none;
box-shadow:0 1px 2px rgba(18,21,40,.06),0 18px 40px -24px rgba(18,21,40,.45);
transform:perspective(800px) rotateX(var(--rx)) rotateY(var(--ry));transition:transform .5s cubic-bezier(.2,.7,.2,1),box-shadow .3s}
.tl:hover{transition-duration:.15s,.3s}
.tl:focus-visible{outline:3px solid #6d28d9;outline-offset:5px;transform:perspective(800px) translateY(-6px)}
.tl:is(:hover,:focus-visible){box-shadow:0 1px 2px rgba(18,21,40,.06),0 30px 50px -24px rgba(18,21,40,.55)}
.tl-frame{position:relative;display:block;aspect-ratio:4/3;overflow:hidden;border-radius:12px}
.tl-frame img{display:block;width:100%;height:100%;object-fit:cover}
.tl-frame::after{content:"";position:absolute;inset:0;opacity:0;transition:opacity .3s;
background:radial-gradient(circle at var(--gx,50%) var(--gy,0%),rgba(255,255,255,.5),rgba(255,255,255,0) 55%)}
.tl:is(:hover,:focus-visible) .tl-frame::after{opacity:1}
.tl-body{padding:14px 4px 4px}
.tl-body .tl-price{font-weight:700;color:#5b21b6}
/* 4. Spotlight border: every card in the group lights the part of its border nearest the pointer */
.sg{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px}
.sg-card{--x:var(--px,50%);--y:var(--py,-30%);position:relative;display:grid;align-content:start;gap:8px;padding:26px 24px;
border:1px solid #d9deea;border-radius:18px;background:#fff;color:#121528;text-decoration:none;line-height:1.45}
.sg-card::before,.sg-card::after{content:"";position:absolute;border-radius:inherit;pointer-events:none;opacity:0;transition:opacity .3s}
.sg-card::before{inset:0;background:radial-gradient(320px circle at var(--x) var(--y),rgba(109,40,217,.1),rgba(109,40,217,0) 65%)}
.sg-card::after{inset:-1px;padding:2px;background:radial-gradient(220px circle at var(--x) var(--y),#7c3aed,rgba(124,58,237,0) 70%);
-webkit-mask:linear-gradient(#000 0 0) content-box,linear-gradient(#000 0 0);-webkit-mask-composite:xor;
mask:linear-gradient(#000 0 0) content-box exclude,linear-gradient(#000 0 0)}
.sg:hover .sg-card::after,.sg-card:is(:hover,:focus-visible)::before,.sg-card:focus-visible::after{opacity:1}
.sg-card:focus-visible{outline:3px solid #6d28d9;outline-offset:4px;--x:50%;--y:0%}
.sg-card>*{position:relative}
.sg-card .sg-icon{width:40px;height:40px;display:grid;place-items:center;border-radius:10px;background:#ede9fe;color:#5b21b6}
.sg-icon svg{width:22px;height:22px}
.sg-card b{font-size:1.05rem;margin-top:6px}
.sg-card span{font-size:.92rem;color:#4b5270}
@media (max-width:767px){.da-grid,.sg{grid-template-columns:minmax(0,1fr)}.da{aspect-ratio:4/3}}
/* Touch: no hover, so the overlay details sit at the bottom of each photo */
@media (hover:none){
.da .da-ov{top:auto;opacity:1;animation:none;transform:none;background:linear-gradient(to top,rgba(30,18,84,.95),rgba(30,18,84,.8) 75%,rgba(30,18,84,0));padding-top:40px}
.da-title{display:none}
}
/* Reduced motion: no sliding or tilting; overlays fade and the spotlights stay centred */
@media (prefers-reduced-motion:reduce){
.da .da-ov{animation:none!important;transform:none!important}
.da[data-da=out]:not(:focus-visible) .da-ov{opacity:0}
.tl{transform:none!important}
}
JavaScript
// Pointer-driven hover effects. Mouse only: touch and keyboard use the static CSS states.
(() => {
const reduce = matchMedia('(prefers-reduced-motion: reduce)');
const isMouse = e => e.pointerType === 'mouse' && !reduce.matches;
// Direction-aware overlays: find the nearest edge and replay the in/out animation from it
const initDirectionAware = () => {
document.querySelectorAll('[data-direction]').forEach(el => {
const ov = el.querySelector('[data-da-overlay]');
if (!ov) return;
const play = (state, e) => {
if (!isMouse(e)) return;
const r = el.getBoundingClientRect();
const x = (e.clientX - r.left) / r.width - .5, y = (e.clientY - r.top) / r.height - .5;
const [dx, dy] = Math.abs(x) > Math.abs(y) ? [x < 0 ? '-100%' : '100%', '0%'] : ['0%', y < 0 ? '-100%' : '100%'];
el.style.setProperty('--da-x', dx); el.style.setProperty('--da-y', dy);
el.dataset.da = state;
ov.style.animation = 'none'; void ov.offsetWidth; ov.style.animation = '';
};
el.addEventListener('pointerenter', e => play('in', e));
el.addEventListener('pointerleave', e => play('out', e));
});
};
// Spotlights: write the pointer position (relative to each target) into --px / --py
const track = (area, targets) => area.addEventListener('pointermove', e => {
if (!isMouse(e)) return;
targets.forEach(t => {
const r = t.getBoundingClientRect();
t.style.setProperty('--px', `${e.clientX - r.left}px`);
t.style.setProperty('--py', `${e.clientY - r.top}px`);
});
});
const initSpotlights = () => {
document.querySelectorAll('[data-spotlight]').forEach(el => track(el, [el]));
document.querySelectorAll('[data-glow-group]').forEach(g => track(g, g.querySelectorAll('[data-glow]')));
};
// Tilt: rotate toward the pointer and move the glare with it
const initTilt = () => {
document.querySelectorAll('[data-tilt]').forEach(el => {
el.addEventListener('pointermove', e => {
if (!isMouse(e)) return;
const r = el.getBoundingClientRect();
const px = (e.clientX - r.left) / r.width, py = (e.clientY - r.top) / r.height;
el.style.setProperty('--rx', `${((.5 - py) * 12).toFixed(2)}deg`);
el.style.setProperty('--ry', `${((px - .5) * 14).toFixed(2)}deg`);
el.style.setProperty('--gx', `${(px * 100).toFixed(1)}%`);
el.style.setProperty('--gy', `${(py * 100).toFixed(1)}%`);
});
el.addEventListener('pointerleave', () => ['--rx', '--ry', '--gx', '--gy'].forEach(p => el.style.removeProperty(p)));
});
};
initDirectionAware(); initSpotlights(); initTilt();
})();
Bootstrap 5 code
Requires: Bootstrap 5.3.8 CSS
HTML
<div class="row gx-4 gy-5">
<div class="col-12"><span class="lbl">Direction-aware overlay</span>
<div class="row g-3">
<div class="col-md-4"><a href="#" class="da ratio d-block rounded-3 text-decoration-none" data-direction><img class="object-fit-cover" src="https://images.unsplash.com/photo-1444703686981-a3abbc4d4fe3?auto=format&fit=crop&w=600&h=750&q=70" alt="A lone figure under the Milky Way on a starry night" width="600" height="750">
<span class="da-title" aria-hidden="true">Atacama Milky Way</span>
<span class="da-ov" data-da-overlay><b>Atacama Milky Way</b><span>5 nights · from $2,900</span><span>Two observatory visits, new-moon dates only.</span><i>View tour →</i></span></a></div>
<div class="col-md-4"><a href="#" class="da ratio d-block rounded-3 text-decoration-none" data-direction><img class="object-fit-cover" src="https://images.unsplash.com/photo-1531366936337-7c912a4589a7?auto=format&fit=crop&w=600&h=750&q=70" alt="Green and violet aurora over a snowy landscape at night" width="600" height="750">
<span class="da-title" aria-hidden="true">Lapland Aurora Week</span>
<span class="da-ov" data-da-overlay><b>Lapland Aurora Week</b><span>6 nights · from $3,400</span><span>Glass cabins and a guide on call every night.</span><i>View tour →</i></span></a></div>
<div class="col-md-4"><a href="#" class="da ratio d-block rounded-3 text-decoration-none" data-direction><img class="object-fit-cover" src="https://images.unsplash.com/photo-1519681393784-d120267933ba?auto=format&fit=crop&w=600&h=750&q=70" alt="Snowy mountain peaks under a sky full of stars" width="600" height="750">
<span class="da-title" aria-hidden="true">Alpine Star Trails</span>
<span class="da-ov" data-da-overlay><b>Alpine Star Trails</b><span>4 nights · from $1,750</span><span>Hut-to-hut walks with long-exposure workshops.</span><i>View tour →</i></span></a></div>
</div></div>
<div class="col-md-6"><span class="lbl">Cursor spotlight</span>
<a href="#" class="sp d-block text-decoration-none" data-spotlight><span class="sp-frame ratio ratio-4x3 d-block"><img class="object-fit-cover" src="https://images.unsplash.com/photo-1440404653325-ab127d49abc1?auto=format&fit=crop&w=900&h=675&q=70" alt="Vintage film reels, a projector and camera parts laid out on a table" width="900" height="675"></span>
<span class="sp-cap"><b>The Projection Room, 1952</b><span>Archive · object 0417 · explore the details</span></span></a></div>
<div class="col-md-6"><span class="lbl">Tilt that follows the pointer</span>
<a href="#" class="tl card text-decoration-none" data-tilt><span class="tl-frame ratio ratio-4x3 d-block"><img class="object-fit-cover" src="https://images.unsplash.com/photo-1505740420928-5e560c06d30e?auto=format&fit=crop&w=900&h=675&q=70" alt="Black over-ear headphones on a bright yellow background" width="900" height="675"></span>
<span class="tl-body card-body p-0 pt-3 px-1"><b>Arc Wireless Headphones</b><span>40-hour battery · folds flat</span><span class="tl-price">$249</span></span></a></div>
<div class="col-12"><span class="lbl">Spotlight border</span>
<div class="sg row g-3" data-glow-group>
<div class="col-md-4"><a href="#" class="sg-card card text-decoration-none" data-glow><span class="card-body"><span class="sg-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9z"/></svg></span>
<b>Dark-sky sites</b><span>Every camp sits inside a dark-sky reserve, at least 60 km from city glow.</span></span></a></div>
<div class="col-md-4"><a href="#" class="sg-card card text-decoration-none" data-glow><span class="card-body"><span class="sg-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="9" cy="8" r="3.5"/><path d="M2.5 20a6.5 6.5 0 0 1 13 0M16 4.5a3.5 3.5 0 0 1 0 7M18 14a6 6 0 0 1 3.5 6"/></svg></span>
<b>Groups of eight</b><span>One guide for every eight guests, so a telescope is always free.</span></span></a></div>
<div class="col-md-4"><a href="#" class="sg-card card text-decoration-none" data-glow><span class="card-body"><span class="sg-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 8h3l2-3h6l2 3h3v11H4z"/><circle cx="12" cy="13" r="3.5"/></svg></span>
<b>Kit included</b><span>Tripods, red head-torches and a loan camera body if you need one.</span></span></a></div>
</div></div>
</div>
CSS
/* Bootstrap grid, .ratio frames and .card surfaces. Direction-aware and cursor-follow effects. A few lines of JavaScript write the pointer position into
CSS custom properties; CSS does the drawing. Without the script, on touch, for keyboard focus and under
reduced motion every card still gets a complete, static hover state. */
/* 1. Direction-aware overlay: slides in from the edge the pointer entered, leaves by the edge it exits */
.da{--da-ease:cubic-bezier(.2,.7,.2,1);--bs-aspect-ratio:125%;overflow:hidden;border-radius:14px;
background:#0b1024;color:#fff;text-decoration:none}
.da:focus-visible{outline:3px solid #6d28d9;outline-offset:4px}
.da>.da-title{top:auto;bottom:0;height:auto;padding:48px 18px 16px;font-size:1.05rem;font-weight:700;line-height:1.25;
background:linear-gradient(to top,rgba(8,10,28,.9),rgba(8,10,28,0))}
.da-ov{z-index:1;display:flex;flex-direction:column;justify-content:flex-end;gap:6px;padding:18px;
background:rgba(30,18,84,.92);line-height:1.35;opacity:0;transition:opacity .3s}
.da-ov b{font-size:1.15rem}
.da-ov span{font-size:.9rem;color:#ddd6fe}
.da-ov i{font-style:normal;font-weight:600;font-size:.88rem;margin-top:8px}
.da:is(:hover,:focus-visible) .da-ov{opacity:1}
.da-title{transition:opacity .3s}
.da:is(:hover,:focus-visible) .da-title,.da[data-da=in] .da-title{opacity:0}
.da[data-da] .da-ov{opacity:1;transition:none}
.da[data-da=in] .da-ov{animation:da-in .38s var(--da-ease) both}
.da[data-da=out] .da-ov{animation:da-out .38s var(--da-ease) both}
.da:focus-visible .da-ov{opacity:1;animation:none;transform:none}
@keyframes da-in{from{transform:translate(var(--da-x,0),var(--da-y,100%))}to{transform:none}}
@keyframes da-out{from{transform:none}to{transform:translate(var(--da-x,0),var(--da-y,100%))}}
/* 2. Cursor spotlight: the photo darkens around a circle that follows the pointer */
.sp{--x:var(--px,50%);--y:var(--py,50%);--sp-r:120px;display:block;border-radius:16px;color:#121528;text-decoration:none}
.sp:focus-visible{outline:3px solid #6d28d9;outline-offset:5px;--x:50%;--y:50%;--sp-r:170px}
.sp-frame{overflow:hidden;border-radius:16px;background:#1a1a22}
.sp-frame::after{content:"";position:absolute;inset:0;opacity:0;transition:opacity .35s;
background:radial-gradient(circle var(--sp-r) at var(--x) var(--y),rgba(9,10,20,0) 55%,rgba(9,10,20,.84))}
.sp:is(:hover,:focus-visible) .sp-frame::after{opacity:1}
.sp-cap,.tl-body{display:grid;gap:2px;padding-top:14px;line-height:1.4}
.sp-cap b,.tl-body b{font-size:1.05rem}
.sp-cap span,.tl-body span{font-size:.88rem;color:#4b5270}
/* 3. Tilt that follows the pointer, with a glare spot */
.tl{--rx:0deg;--ry:0deg;--bs-card-border-width:0;--bs-card-border-radius:20px;padding:16px;color:#121528;
box-shadow:0 1px 2px rgba(18,21,40,.06),0 18px 40px -24px rgba(18,21,40,.45);
transform:perspective(800px) rotateX(var(--rx)) rotateY(var(--ry));transition:transform .5s cubic-bezier(.2,.7,.2,1),box-shadow .3s}
.tl:hover{transition-duration:.15s,.3s}
.tl:focus-visible{outline:3px solid #6d28d9;outline-offset:5px;transform:perspective(800px) translateY(-6px)}
.tl:is(:hover,:focus-visible){box-shadow:0 1px 2px rgba(18,21,40,.06),0 30px 50px -24px rgba(18,21,40,.55)}
.tl-frame{overflow:hidden;border-radius:12px}
.tl-frame::after{content:"";position:absolute;inset:0;opacity:0;transition:opacity .3s;
background:radial-gradient(circle at var(--gx,50%) var(--gy,0%),rgba(255,255,255,.5),rgba(255,255,255,0) 55%)}
.tl:is(:hover,:focus-visible) .tl-frame::after{opacity:1}
.tl-body{padding:14px 4px 4px;flex:none}
.tl-body .tl-price{font-weight:700;color:#5b21b6}
/* 4. Spotlight border: every card in the group lights the part of its border nearest the pointer */
.sg-card{--x:var(--px,50%);--y:var(--py,-30%);--bs-card-border-color:#d9deea;--bs-card-border-radius:18px;--bs-card-spacer-x:24px;--bs-card-spacer-y:26px;
height:100%;color:#121528;line-height:1.45}
.sg-card .card-body{display:grid;align-content:start;gap:8px}
.sg-card::before,.sg-card::after{content:"";position:absolute;border-radius:inherit;pointer-events:none;opacity:0;transition:opacity .3s}
.sg-card::before{inset:0;background:radial-gradient(320px circle at var(--x) var(--y),rgba(109,40,217,.1),rgba(109,40,217,0) 65%)}
.sg-card::after{inset:-1px;padding:2px;background:radial-gradient(220px circle at var(--x) var(--y),#7c3aed,rgba(124,58,237,0) 70%);
-webkit-mask:linear-gradient(#000 0 0) content-box,linear-gradient(#000 0 0);-webkit-mask-composite:xor;
mask:linear-gradient(#000 0 0) content-box exclude,linear-gradient(#000 0 0)}
.sg:hover .sg-card::after,.sg-card:is(:hover,:focus-visible)::before,.sg-card:focus-visible::after{opacity:1}
.sg-card:focus-visible{outline:3px solid #6d28d9;outline-offset:4px;--x:50%;--y:0%}
.sg-card>*{position:relative}
.sg-card .sg-icon{width:40px;height:40px;display:grid;place-items:center;border-radius:10px;background:#ede9fe;color:#5b21b6}
.sg-icon svg{width:22px;height:22px}
.sg-card b{font-size:1.05rem;margin-top:6px}
.sg-card .card-body{position:relative}
.sg-card span{font-size:.92rem;color:#4b5270}
@media (max-width:767px){.da{--bs-aspect-ratio:75%}}
/* Touch: no hover, so the overlay details sit at the bottom of each photo */
@media (hover:none){
.da .da-ov{top:auto;opacity:1;animation:none;transform:none;background:linear-gradient(to top,rgba(30,18,84,.95),rgba(30,18,84,.8) 75%,rgba(30,18,84,0));padding-top:40px}
.da-title{display:none}
}
/* Reduced motion: no sliding or tilting; overlays fade and the spotlights stay centred */
@media (prefers-reduced-motion:reduce){
.da .da-ov{animation:none!important;transform:none!important}
.da[data-da=out]:not(:focus-visible) .da-ov{opacity:0}
.tl{transform:none!important}
}
JavaScript
// Pointer-driven hover effects. Mouse only: touch and keyboard use the static CSS states.
(() => {
const reduce = matchMedia('(prefers-reduced-motion: reduce)');
const isMouse = e => e.pointerType === 'mouse' && !reduce.matches;
// Direction-aware overlays: find the nearest edge and replay the in/out animation from it
const initDirectionAware = () => {
document.querySelectorAll('[data-direction]').forEach(el => {
const ov = el.querySelector('[data-da-overlay]');
if (!ov) return;
const play = (state, e) => {
if (!isMouse(e)) return;
const r = el.getBoundingClientRect();
const x = (e.clientX - r.left) / r.width - .5, y = (e.clientY - r.top) / r.height - .5;
const [dx, dy] = Math.abs(x) > Math.abs(y) ? [x < 0 ? '-100%' : '100%', '0%'] : ['0%', y < 0 ? '-100%' : '100%'];
el.style.setProperty('--da-x', dx); el.style.setProperty('--da-y', dy);
el.dataset.da = state;
ov.style.animation = 'none'; void ov.offsetWidth; ov.style.animation = '';
};
el.addEventListener('pointerenter', e => play('in', e));
el.addEventListener('pointerleave', e => play('out', e));
});
};
// Spotlights: write the pointer position (relative to each target) into --px / --py
const track = (area, targets) => area.addEventListener('pointermove', e => {
if (!isMouse(e)) return;
targets.forEach(t => {
const r = t.getBoundingClientRect();
t.style.setProperty('--px', `${e.clientX - r.left}px`);
t.style.setProperty('--py', `${e.clientY - r.top}px`);
});
});
const initSpotlights = () => {
document.querySelectorAll('[data-spotlight]').forEach(el => track(el, [el]));
document.querySelectorAll('[data-glow-group]').forEach(g => track(g, g.querySelectorAll('[data-glow]')));
};
// Tilt: rotate toward the pointer and move the glare with it
const initTilt = () => {
document.querySelectorAll('[data-tilt]').forEach(el => {
el.addEventListener('pointermove', e => {
if (!isMouse(e)) return;
const r = el.getBoundingClientRect();
const px = (e.clientX - r.left) / r.width, py = (e.clientY - r.top) / r.height;
el.style.setProperty('--rx', `${((.5 - py) * 12).toFixed(2)}deg`);
el.style.setProperty('--ry', `${((px - .5) * 14).toFixed(2)}deg`);
el.style.setProperty('--gx', `${(px * 100).toFixed(1)}%`);
el.style.setProperty('--gy', `${(py * 100).toFixed(1)}%`);
});
el.addEventListener('pointerleave', () => ['--rx', '--ry', '--gx', '--gy'].forEach(p => el.style.removeProperty(p)));
});
};
initDirectionAware(); initSpotlights(); initTilt();
})();
/* touch: screens without hover show the overlay details at the bottom of each photo */
@custom-variant touch (@media (hover: none));
@theme {
--color-violet: #6d28d9;
--color-ink: #121528;
--color-muted: #4b5270;
}
/* the script sets --da-x / --da-y to the edge the pointer crossed */
@keyframes da-in { from { transform: translate(var(--da-x, 0), var(--da-y, 100%)); } to { transform: none; } }
@keyframes da-out { from { transform: none; } to { transform: translate(var(--da-x, 0), var(--da-y, 100%)); } }
JavaScript
// Pointer-driven hover effects. Mouse only: touch and keyboard use the static CSS states.
(() => {
const reduce = matchMedia('(prefers-reduced-motion: reduce)');
const isMouse = e => e.pointerType === 'mouse' && !reduce.matches;
// Direction-aware overlays: find the nearest edge and replay the in/out animation from it
const initDirectionAware = () => {
document.querySelectorAll('[data-direction]').forEach(el => {
const ov = el.querySelector('[data-da-overlay]');
if (!ov) return;
const play = (state, e) => {
if (!isMouse(e)) return;
const r = el.getBoundingClientRect();
const x = (e.clientX - r.left) / r.width - .5, y = (e.clientY - r.top) / r.height - .5;
const [dx, dy] = Math.abs(x) > Math.abs(y) ? [x < 0 ? '-100%' : '100%', '0%'] : ['0%', y < 0 ? '-100%' : '100%'];
el.style.setProperty('--da-x', dx); el.style.setProperty('--da-y', dy);
el.dataset.da = state;
ov.style.animation = 'none'; void ov.offsetWidth; ov.style.animation = '';
};
el.addEventListener('pointerenter', e => play('in', e));
el.addEventListener('pointerleave', e => play('out', e));
});
};
// Spotlights: write the pointer position (relative to each target) into --px / --py
const track = (area, targets) => area.addEventListener('pointermove', e => {
if (!isMouse(e)) return;
targets.forEach(t => {
const r = t.getBoundingClientRect();
t.style.setProperty('--px', `${e.clientX - r.left}px`);
t.style.setProperty('--py', `${e.clientY - r.top}px`);
});
});
const initSpotlights = () => {
document.querySelectorAll('[data-spotlight]').forEach(el => track(el, [el]));
document.querySelectorAll('[data-glow-group]').forEach(g => track(g, g.querySelectorAll('[data-glow]')));
};
// Tilt: rotate toward the pointer and move the glare with it
const initTilt = () => {
document.querySelectorAll('[data-tilt]').forEach(el => {
el.addEventListener('pointermove', e => {
if (!isMouse(e)) return;
const r = el.getBoundingClientRect();
const px = (e.clientX - r.left) / r.width, py = (e.clientY - r.top) / r.height;
el.style.setProperty('--rx', `${((.5 - py) * 12).toFixed(2)}deg`);
el.style.setProperty('--ry', `${((px - .5) * 14).toFixed(2)}deg`);
el.style.setProperty('--gx', `${(px * 100).toFixed(1)}%`);
el.style.setProperty('--gy', `${(py * 100).toFixed(1)}%`);
});
el.addEventListener('pointerleave', () => ['--rx', '--ry', '--gx', '--gy'].forEach(p => el.style.removeProperty(p)));
});
};
initDirectionAware(); initSpotlights(); initTilt();
})();
About this set
Four pointer-driven hover effects with a few lines of vanilla JavaScript, shown on a night-sky photography tour site. Direction-aware overlay slides the tour details in from whichever edge the pointer entered and out through the edge it leaves; the script finds the nearest edge, writes it to two custom properties and replays a keyframe animation. Cursor spotlight darkens an archive photo except for a soft circle that follows the pointer. Tilt that follows the pointer rotates a product card toward the cursor and moves a glare spot with it. Spotlight border lights the part of each feature card’s border nearest the pointer, across the whole group, using a radial gradient masked to a two-pixel ring. The script only writes CSS custom properties and listens to mouse pointers. Without it, on touch screens, for keyboard focus and under reduced motion every card still has a complete static state: the overlay fades in, the spotlight and glow sit centred, and the focused tilt card lifts instead of rotating.
What’s included
Direction-aware overlay that enters and leaves by the nearest edge
Cursor spotlight on a photo and a glowing border across a card group
Pointer-following 3D tilt with a moving glare
One short vanilla script, no libraries, mouse pointers only
Static equivalents for keyboard focus, touch and reduced motion
Accessibility
Every card is a single link with a descriptive name; the duplicated title shown at rest is aria-hidden. Keyboard focus shows a 3px violet outline and a fixed version of each effect: the overlay fades in, the spotlight and border glow centre themselves, and the tilt card lifts. The script ignores touch and pen input and does nothing when prefers-reduced-motion is set, and the CSS removes the slide and tilt in that case.
Customise it
Colours sit in the .da-ov, .sp-frame::after, .tl-frame::after and .sg-card rules; --sp-r sets the spotlight radius and the multipliers in the tilt script set the maximum angle. In Tailwind change the violet, ink and muted theme colours and the radial-gradient utilities.