<!– WORDPRESS CUSTOM HTML PROTOTYPE
Paste this entire file into one Custom HTML block.
1. Replace YOUR_VIDEO_URL.mp4 with the File URL of an MP4 in your Media Library.
2. Edit the messages in the
- below; each
- becomes one bubble.
3. Timing is editable in the script: cycleMs and lifetimeMs.
View the saved page on the front end to test JavaScript.
–>Read all messages
- Pause here. There is a whole world moving beneath the surface.
- The wind touches the water, and the water carries its story.
- Every shore belongs to a larger journey.
- Watch closely. What seems still may be changing.
.ob-scene, .ob-scene * { box-sizing: border-box; }
.ob-scene {
position: relative; isolation: isolate; overflow: hidden;
width: 100%; min-height: 480px; height: min(75vh, 760px);
border-radius: 18px; background: radial-gradient(ellipse at 60% 35%, #245866, #061e2c 75%);
color: #fff; font-family: inherit;
}
.ob-scene .ob-video, .ob-scene .ob-shade, .ob-scene .ob-stage {
position: absolute; inset: 0; width: 100%; height: 100%;
}
.ob-scene .ob-video { object-fit: cover; z-index: 0; }
.ob-scene .ob-shade {
z-index: 1; pointer-events: none;
background: linear-gradient(180deg, rgba(0,15,25,.12), rgba(0,15,25,.2) 60%, rgba(0,15,25,.7));
}
.ob-scene .ob-stage { z-index: 2; pointer-events: none; }
.ob-scene .ob-bubble, .ob-scene .ob-fallback {
position: absolute; left: 50%; top: 43%; margin: 0;
width: min(70%, 520px); padding: 25px 32px;
border: 1px solid rgba(255,255,255,.3); border-radius: 30px;
background: rgba(5,27,39,.72); color: #fff;
font-size: clamp(19px, 2.6vw, 29px); line-height: 1.45;
font-weight: 400; text-align: center; text-shadow: 0 1px 3px rgba(0,0,0,.3);
box-shadow: 0 16px 55px rgba(0,0,0,.15);
transform: translate(-50%, -50%);
}
.ob-scene .ob-bubble { will-change: transform, opacity; }
.ob-scene .ob-controls {
position: absolute; z-index: 3; left: 20px; right: 20px; bottom: 20px;
display: flex; align-items: flex-end; gap: 12px;
}
.ob-scene .ob-pause, .ob-scene .ob-transcript {
color: #fff; background: rgba(4,23,33,.92);
border: 1px solid rgba(255,255,255,.35); border-radius: 12px;
font: inherit; font-size: 14px; line-height: 1.5;
}
.ob-scene .ob-pause { padding: 10px 15px; cursor: pointer; flex-shrink: 0; }
.ob-scene .ob-pause[hidden] { display: none; }
.ob-scene .ob-transcript { max-width: 560px; }
.ob-scene .ob-transcript summary { padding: 10px 15px; cursor: pointer; }
.ob-scene .ob-transcript ol { margin: 0; padding: 0 24px 16px 38px; max-height: 230px; overflow: auto; }
.ob-scene .ob-transcript li { margin-bottom: 10px; }
.ob-scene button:focus-visible, .ob-scene summary:focus-visible { outline: 3px solid #a8e6ff; outline-offset: 3px; }
@media (max-width: 600px) {
.ob-scene { min-height: 500px; border-radius: 12px; }
.ob-scene .ob-bubble, .ob-scene .ob-fallback { width: 86%; padding: 22px; }
.ob-scene .ob-controls { left: 12px; right: 12px; bottom: 12px; flex-wrap: wrap; }
}
@media (prefers-reduced-motion: reduce) {
.ob-scene .ob-video { visibility: hidden; }
}(function () {
document.querySelectorAll(‘.ob-scene’).forEach(function (scene) {
if (scene.dataset.obReady) return;
scene.dataset.obReady = ‘true’;
const stage = scene.querySelector(‘.ob-stage’);
const video = scene.querySelector(‘.ob-video’);
const button = scene.querySelector(‘.ob-pause’);
const transcript = scene.querySelector(‘.ob-transcript’);
const messages = Array.from(transcript.querySelectorAll(‘li’), li => li.textContent.trim()).filter(Boolean);
const reduced = window.matchMedia(‘(prefers-reduced-motion: reduce)’);
if (!messages.length || !stage.animate) return;// EDIT TIMING: a new message every 9 seconds; each lasts 11 seconds.
// The last 3 seconds are the retreat, giving 2 seconds of overlap.
const cycleMs = 9000;
const lifetimeMs = 11000;
let userPaused = false, elapsed = 0, nextAt = 0, index = 0, previous = null, frame;
const animations = new Set();
const positions = [ [46,42], [58,47], [43,49], [54,39] ];
stage.replaceChildren();
button.hidden = false;function stopped() {
return userPaused || reduced.matches || document.hidden || transcript.open;
}function sync() {
const pause = stopped();
animations.forEach(a => pause ? a.pause() : a.play());
if (pause) video.pause();
else {
const attempt = video.play();
if (attempt) attempt.catch(function () {});
}button.textContent = userPaused ? ‘Resume motion’ : ‘Pause motion’;
button.hidden = reduced.matches;if (reduced.matches) {
animations.forEach(a => a.cancel());
animations.clear();
stage.replaceChildren();
const text = document.createElement(‘p’);
text.className = ‘ob-fallback’;
text.textContent = messages[0];
stage.appendChild(text);
} else {
const fallback = stage.querySelector(‘.ob-fallback’);
if (fallback) {
fallback.remove();
nextAt = elapsed;
}
}
}function spawn() {
const bubble = document.createElement(‘p’);
bubble.className = ‘ob-bubble’;
bubble.textContent = messages[index % messages.length];const position = window.matchMedia(‘(max-width: 600px)’).matches
? [50,43]
: positions[index % positions.length];const dx = (50 – position[0]) * stage.clientWidth / 100;
const dy = (32 – position[1]) * stage.clientHeight / 100;bubble.style.left = position[0] + ‘%’;
bubble.style.top = position[1] + ‘%’;
stage.appendChild(bubble);
index++;const base = ‘translate(-50%, -50%) perspective(900px)’;
const animation = bubble.animate([
{
opacity: 0,
transform: base + ‘ translateZ(30px)’,
offset: 0
},
{
opacity: 1,
transform: base + ‘ translateZ(0)’,
offset: 1000 / lifetimeMs
},
{
opacity: 1,
transform: base + ‘ translateZ(0)’,
offset: (lifetimeMs – 3000) / lifetimeMs
},
{
opacity: 0,
transform: ‘translate(calc(-50% + ‘ + dx + ‘px), calc(-50% + ‘ + dy + ‘px)) perspective(900px) translateZ(-650px)’,
offset: 1
}
], {
duration: lifetimeMs,
easing: ‘ease-in-out’,
fill: ‘forwards’
});animations.add(animation);
animation.finished.then(
function () {
animations.delete(animation);
bubble.remove();
},
function () {
bubble.remove();
}
);
}function tick(now) {
if (!scene.isConnected) {
animations.forEach(a => a.cancel());
video.pause();
return;
}if (previous !== null && !stopped()) {
elapsed += Math.min(now – previous, 100);
}previous = now;
if (!stopped() && elapsed >= nextAt) {
spawn();
nextAt = elapsed + cycleMs;
}frame = requestAnimationFrame(tick);
}button.addEventListener(‘click’, function () {
userPaused = !userPaused;
sync();
});transcript.addEventListener(‘toggle’, sync);
document.addEventListener(‘visibilitychange’, sync);
reduced.addEventListener(‘change’, sync);sync();
frame = requestAnimationFrame(tick);
});
})();