I built a Before / After Image Slider in Oracle APEX and here is how it actually went
I had a page in my APEX app where I wanted to show a before and after image, the kind you see on design and photo editing sites where you drag a handle in the middle and the picture changes. APEX does not give you this out of the box, so I had to build it myself using a plain static content region. This post is basically my notes while I was building it, including the parts that failed.
The first problem I ran into
My first idea was simple. Put two images on top of each other, absolute position both of them, and just change the width of the top one using a range input. Sounds easy right. I tried this and the slider itself worked, but the image quality looked broken while dragging. The "before" image was getting squashed and stretched because when I reduced the width of its wrapper div, the image inside it was also shrinking along with the div instead of staying the same size and just getting clipped.
What I tried next
I tried using clip-path instead of resizing the wrapper width. This actually fixed the squashing issue since the image kept its full size and only the visible portion changed. But then a new issue came up on my Chennai office wifi test, the clip-path version was not smooth on mobile Safari, dragging felt laggy compared to Chrome. I did not want to spend more days debugging vendor specific clip-path quirks, so I went back to the wrapper width approach and fixed it a different way.
Finally this approach worked. I kept the width based clipping wrapper, but I fixed the inner image width using JavaScript instead of CSS percentage. So instead of letting the "before" image shrink with its wrapper, I set its width equal to the full slider width in pixels using a CSS variable that gets updated on load and on resize. That way the wrapper clips the image but the image itself never actually changes size, so no squashing.
rough idea of how the handle moves, actual slider has your real images
Getting it to work inside an APEX region
Since APEX static content regions accept raw HTML, CSS and JS in one box, I did not need any plugin for this. I just referenced my images using #APP_FILES# so they load from the application static files, and kept everything scoped under one wrapper class with a data attribute, so if I need two of these sliders on the same page later, they will not clash with each other.
One more small issue I faced, the handle position was resetting to 50 percent every time the page refreshed the region using dynamic actions, since the JS runs again on page load. I left it that way on purpose actually, since a fresh 50 percent split looks cleaner for first time visitors compared to remembering old drag positions.
Full working code
Below is the complete static content region code I am using right now. Paste this directly into a Static Content region source in APEX, no plugin needed. Just replace the two image file names with your own uploaded static application files.
<div class="ba-wrap" data-ba-id="ba1">
<div class="ba-slider">
<img class="ba-img ba-after" src="#APP_FILES#after.png" alt="After">
<div class="ba-before-wrap">
<img class="ba-img ba-before" src="#APP_FILES#before.jpg" alt="Before">
</div>
<span class="ba-tag ba-tag-before">BEFORE</span>
<span class="ba-tag ba-tag-after">AFTER</span>
<div class="ba-handle">
<div class="ba-handle-line"></div>
<div class="ba-handle-btn">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<path d="M14 6l-6 6 6 6" stroke="#2563eb" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M10 6l6 6-6 6" stroke="#2563eb" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
</div>
</div>
<p class="ba-hint">Drag the handle to compare ↔ </p>
</div>
<style>
.ba-wrap{
max-width: 760px;
margin: 30px auto;
font-family: 'Segoe UI', Roboto, Arial, sans-serif;
text-align: center;
}
.ba-slider{
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
overflow: hidden;
border-radius: 16px;
background: #f1f5f9;
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.12), 0 2px 6px rgba(15,23,42,0.06);
border: 1px solid #e5e9f0;
user-select: none;
}
.ba-img{
position: absolute;
top: 0; left: 0;
width: 100%; height: 100%;
object-fit: cover;
display: block;
pointer-events: none;
}
.ba-before-wrap{
position: absolute;
top: 0; left: 0;
width: 50%;
height: 100%;
overflow: hidden;
border-right: 2px solid #ffffff;
}
.ba-before-wrap .ba-img{
width: var(--ba-img-w, 100%);
max-width: none;
}
.ba-tag{
position: absolute;
top: 14px;
padding: 5px 12px;
font-size: 12px;
font-weight: 700;
letter-spacing: .06em;
border-radius: 999px;
background: rgba(255,255,255,0.85);
color: #1e293b;
backdrop-filter: blur(4px);
box-shadow: 0 2px 6px rgba(0,0,0,0.08);
animation: baFadeIn .6s ease both;
}
.ba-tag-before{ left: 14px; animation-delay: .1s;}
.ba-tag-after{ right: 14px; animation-delay: .25s;}
.ba-handle{
position: absolute;
top: 0;
left: 50%;
height: 100%;
width: 0;
transform: translateX(-50%);
cursor: ew-resize;
z-index: 5;
}
.ba-handle-line{
position: absolute;
top: 0; bottom: 0; left: 50%;
width: 2px;
transform: translateX(-50%);
background: #ffffff;
box-shadow: 0 0 0 1px rgba(0,0,0,0.05);
}
.ba-handle-btn{
position: absolute;
top: 50%; left: 50%;
width: 44px; height: 44px;
transform: translate(-50%, -50%);
background: #ffffff;
border-radius: 50%;
display: flex; align-items: center; justify-content: center;
gap: 2px;
box-shadow: 0 4px 14px rgba(15,23,42,0.25);
transition: transform .15s ease, box-shadow .15s ease;
animation: baPulse 2.2s ease-in-out 3;
}
.ba-handle:hover .ba-handle-btn,
.ba-handle:active .ba-handle-btn{
transform: translate(-50%, -50%) scale(1.08);
box-shadow: 0 6px 18px rgba(37,99,235,0.35);
}
.ba-hint{
margin-top: 10px;
font-size: 13px;
color: #64748b;
letter-spacing: .02em;
}
@keyframes baFadeIn{
from{ opacity: 0; transform: translateY(-6px); }
to{ opacity: 1; transform: translateY(0); }
}
@keyframes baPulse{
0%, 100% { box-shadow: 0 4px 14px rgba(15,23,42,0.25), 0 0 0 0 rgba(37,99,235,0.35); }
50% { box-shadow: 0 4px 14px rgba(15,23,42,0.25), 0 0 0 10px rgba(37,99,235,0); }
}
@media (max-width: 480px){
.ba-handle-btn{ width: 38px; height: 38px; }
.ba-tag{ font-size: 10px; padding: 4px 9px; }
}
</style>
<script>
(function(){
document.querySelectorAll('.ba-wrap').forEach(function(wrap){
var slider = wrap.querySelector('.ba-slider');
var beforeW = wrap.querySelector('.ba-before-wrap');
var handle = wrap.querySelector('.ba-handle');
var afterImg = wrap.querySelector('.ba-after');
var dragging = false;
function syncImageWidth(){
var w = slider.getBoundingClientRect().width;
beforeW.style.setProperty('--ba-img-w', w + 'px');
}
function setPosition(clientX){
var rect = slider.getBoundingClientRect();
var x = clientX - rect.left;
var pct = Math.min(100, Math.max(0, (x / rect.width) * 100));
beforeW.style.width = pct + '%';
handle.style.left = pct + '%';
}
function start(e){
dragging = true;
slider.style.cursor = 'ew-resize';
move(e);
}
function end(){ dragging = false; }
function move(e){
if(!dragging && e.type !== 'mousedown' && e.type !== 'touchstart') return;
var clientX = e.touches ? e.touches[0].clientX : e.clientX;
setPosition(clientX);
}
handle.addEventListener('mousedown', start);
slider.addEventListener('mousedown', start);
window.addEventListener('mousemove', function(e){ if(dragging) move(e); });
window.addEventListener('mouseup', end);
handle.addEventListener('touchstart', start, {passive:true});
slider.addEventListener('touchstart', start, {passive:true});
window.addEventListener('touchmove', function(e){ if(dragging) move(e); }, {passive:true});
window.addEventListener('touchend', end);
window.addEventListener('resize', syncImageWidth);
afterImg.addEventListener('load', syncImageWidth);
syncImageWidth();
});
})();
</script>
Things to keep in mind if you copy this
- Upload your two images as Application static files first, then swap the file names in the code, keep the same aspect ratio for both images or the slider looks odd while dragging.
- If you are placing more than one slider on the same page, just change data-ba-id and the class name stays the same, the script already loops through every .ba-wrap on the page so it handles multiple sliders on its own.
- Test on an actual phone, not just the browser dev tools mobile view, touch dragging behaves a bit different on real devices.
Try it yourself
I have hosted a working version of this on my APEX workspace and also pushed the full code to GitHub in case you want to fork it or read through the commit history of how I changed the approach a few times.
Comments
Post a Comment