24

I'm trying to do a scale transformation on a div that is centered on a scrollable container div.

The trick i'm using to reflect the new div size after transformation, is using a wrapper and setting the new width/height to it so the parent can show the scrollbars correctly.

.container {
    position: relative;
    border: 3px solid red;
    width: 600px; height: 400px;
    background-color: blue;
    overflow-x: scroll; overflow-y: scroll;
    display: flex;
    flex-direction: row;
    align-items: center;
    justify-content: center;
}
.wrapper {
    order: 1;
    background-color: yellow;
}
.content-outer {
    width: 300px;
    height: 200px;
    transform-origin: 50% 50%;
    /*transform-origin: 0 0;*/
}
.content-outer.animatted {
    animation: scaleAnimation 1s ease-in forwards;
}
.content-outer.animatted2 {
    animation: scaleAnimation2 1s ease-in forwards;
}
.content-inner {
    width: 300px;
    height: 200px;
    background: linear-gradient(to right, red, white);
}

if the transformation origin was 0,0 the div is centered without animation jumps but the scrollbars are not correct. if the origin was in the middle both the div location and scrollbars are missed up

I have tried two ways to do the centering, using flexbox (http://jsfiddle.net/r3jqyjLz/1/) and using negative margins (http://jsfiddle.net/roLf5tph/1/).

Is there a better way to do this ?

Ahmed Kotb
  • 6,269
  • 6
  • 33
  • 52
  • You can't do this. Left and top (or right if `direction:rtl`) are non-scrollable areas in every browser I've encountered. That's generally a holdover from the days of hiding elements doing the `position: absolute; left: -999999px;` hack. You can use one of several third-party tools for custom scroll out there, but honestly I'd question the design that lead to this. – Josh Burgess Jul 14 '15 at 19:04

5 Answers5

8

Is this what you are after?

I used CSS transitions for the scaling animation.

#centered {
    background-image: linear-gradient(to bottom,yellow,red);
    height: 200px;
    transform: scale(1);
    transition: transform 1s ease-out;
    width: 200px;
}

#scrollable {
    align-items: center;
    border: 1px solid red;
    display: flex;
    height: 300px;
    justify-content: center;
    overflow: auto;
    width: 300px;
}

Update #1

How about this one?

  • Using old school absolute centering (position absolute)
  • I see your point about flexbox. It seems that flexbox centering has some limitations when the centered element is larger than its container.
Matthew King
  • 1,342
  • 7
  • 13
  • this has the exact same problem, scrolling is not working properly,(https://jsfiddle.net/6e2g6vzt/2/ ) i have replaced the gradient with a random image of the same size so the scrolling problem is more obvious. (try scale 3, image is cropped from the top, face of the guy is not showing :) ) – Ahmed Kotb Jul 05 '15 at 10:49
  • the updated solution has the same problem, https://dl.dropboxusercontent.com/u/8045913/temp/problem.PNG. i have tried negative margins in the second link in the question – Ahmed Kotb Jul 05 '15 at 19:08
8

I am assuming that parts of the problem you are having can be summarized as:

  1. You have a complex structure in your inner div which you want to scale as a group. (If it were a single element, this would've been easy with a matrix).
  2. When the inner div is scaled beyond the bounds of the container, you don't get scrollbars without controlling the width/height.
  3. You want the inner div to remain centered and at the same time, scrollbars should reflect the correct position.

With this, there are two (three actually) easy options.

Option 1:

Using the same markup in the question, you could keep the div centered when the scale factor is below 1. And for scale factors above 1, you change it to top-left. the overflow: auto on the container will take care of the scroll on its own because the div being scaled (via transform) is wrapped inside of another div. You don't really need Javascript for this.

This solves your problems 1 and 2.

Fiddle 1: http://jsfiddle.net/abhitalks/c0okhznc/

Snippet 1:

* { box-sizing: border-box; }
#container {
    position: relative;
    border: 1px solid red; background-color: blue;
    width: 400px; height: 300px;
    overflow: auto;
}
.wrapper {
    position: absolute; 
    background-color: transparent; border: 2px solid black;
    width: 300px; height: 200px;
    top: 50%; left: 50%;
    transform-origin: top left;
    transform: translate(-50%, -50%); 
    transition: all 1s;
}
.box {
    width: 300px; height: 200px;
    background: linear-gradient(to right, red, white);
    border: 2px solid yellow;
}
.inner { width:50px; height: 50px; background-color: green; }

#s0:checked ~ div#container .wrapper { transform: scale(0.5) translate(-50%, -50%); }
#s1:checked ~ div#container .wrapper { transform: scale(0.75) translate(-50%, -50%); }
#s2:checked ~ div#container .wrapper { transform: scale(1) translate(-50%, -50%); }
#s3:checked ~ div#container .wrapper { top: 0%; left: 0%; transform-origin: top left; transform: scale(1.5) translate(0%, 0%); }
#s4:checked ~ div#container .wrapper { top: 0%; left: 0%; transform-origin: top left; transform: scale(2) ; }
#s5:checked ~ div#container .wrapper { top: 0%; left: 0%; transform-origin: top left; transform: scale(3) ; }
<input id="s0" name="scale" data-scale="0.5" type="radio" />Scale 0.5
<input id="s1" name="scale" data-scale="0.75" type="radio" />Scale 0.75
<input id="s2" name="scale" data-scale="1" type="radio" checked />Scale 1
<input id="s3" name="scale" data-scale="1.5" type="radio" />Scale 1.5
<input id="s4" name="scale" data-scale="2" type="radio" />Scale 2
<input id="s5" name="scale" data-scale="3" type="radio" />Scale 3
<hr>
<div id="container">
    <div id="wrapper" class="wrapper">
        <div id="box" class="box">
            <div class="inner"></div>
        </div>
    </div>
</div>

But this creates another problem. The overflow:auto will cause a jump/flicker when the div is scaled beyond the container. This can be easily solved by making it overflow:scroll to show the scrollbars at all times (the way you are doing it already). Although, it works like a charm in Firefox, Chrome falters here and doesn't update the scrollbar position. The trick here is to use Javascript to force a reflow by changing the overflow to auto once your scaling completes. So you need to delay it a bit.

Fiddle 2: http://jsfiddle.net/abhitalks/u7sfef0b/

Snippet 2:

$("input").on("click", function() {
    var scroll = 'scroll', 
        scale = +($(this).data("scale"));
    if (scale > 1) { scroll = 'auto'; }
    setTimeout(fixScroll, 300, scroll);
});

function fixScroll(scroll) { $("#container").css({ overflow: scroll }); }
* { box-sizing: border-box; }
#container {
    position: relative;
    border: 1px solid red; background-color: blue;
    width: 400px; height: 300px;
    overflow: scroll;
}
.wrapper {
    position: absolute; 
    background-color: transparent; border: 2px solid black;
    width: 300px; height: 200px;
    top: 50%; left: 50%;
    transform-origin: top left;
    transform: translate(-50%, -50%); 
    transition: all 1s;
}
.box {
    width: 300px; height: 200px;
    background: linear-gradient(to right, red, white);
    border: 2px solid yellow;
}
.inner { width:50px; height: 50px; background-color: green; }

#s0:checked ~ div#container .wrapper { transform: scale(0.5) translate(-50%, -50%); }
#s1:checked ~ div#container .wrapper { transform: scale(0.75) translate(-50%, -50%); }
#s2:checked ~ div#container .wrapper { transform: scale(1) translate(-50%, -50%); }
#s3:checked ~ div#container .wrapper { top: 0%; left: 0%;transform-origin: top left; transform: scale(1.5) translate(0%, 0%); }
#s4:checked ~ div#container .wrapper { top: 0%; left: 0%;transform-origin: top left; transform: scale(2) ; }
#s5:checked ~ div#container .wrapper { top: 0%; left: 0%;transform-origin: top left; transform: scale(3) ; }
<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
<input id="s0" name="scale" data-scale="0.5" type="radio" />Scale 0.5
<input id="s1" name="scale" data-scale="0.75" type="radio" />Scale 0.75
<input id="s2" name="scale" data-scale="1" type="radio" checked />Scale 1
<input id="s3" name="scale" data-scale="1.5" type="radio" />Scale 1.5
<input id="s4" name="scale" data-scale="2" type="radio" />Scale 2
<input id="s5" name="scale" data-scale="3" type="radio" />Scale 3
<hr>
<div id="container">
    <div id="wrapper" class="wrapper">
        <div id="box" class="box">
            <div class="inner"></div>
        </div>
    </div>
</div>

Option 2:

In order to solve problem 3 of keeping the div centered and at the same time maintaining the correct scrollbar position, you have to fallback on Javascript.

The principle remains the same that for scale factors above 1 you need to reset the top-left and translate positions. You would also need to recalc the scaled width/height and then re-assign that to your wrapper div. Then setting the scrollTop and scrollLeft on the container will be as easy as just getting the difference of the wrapper div and the container div.

This solves your problems 1, 2, and 3.

Fiddle 3: http://jsfiddle.net/abhitalks/rheo6o7p/1/

Snippet 3:

var $container = $("#container"), 
    $wrap = $("#wrapper"), 
    $elem = $("#box"), 
    originalWidth = 300, originalHeight = 200;

$("input").on("click", function() {
    var factor = +(this.value);
    scaler(factor);
});

function scaler(factor) {
    var newWidth = originalWidth * factor,
        newHeight = originalHeight * factor;
    $wrap.width(newWidth); $wrap.height(newHeight);
    if (factor > 1) {
        $wrap.css({ left: 0, top: 0, transform: 'translate(0,0)' }); 
        $elem.css({ transform: 'scale(' + factor + ')' }); 
        setTimeout(setScroll, 400);
    } else {
        $elem.css({ transform: 'scale(' + factor + ') ' });        
        $wrap.css({ left: '50%', top: '50%', transform: 'translate(-50%, -50%)' });
    }
}

function setScroll() {
    var horizontal, vertical;
    horizontal = ($wrap.width() - $container.width()) / 2;
    vertical = ($wrap.height() - $container.height()) / 2;
    $container.stop().animate({scrollTop: vertical, scrollLeft: horizontal}, 500);
}
* { box-sizing: border-box; }
#container {
    position: relative;
    border: 1px solid red; background-color: blue;
    width: 400px; height: 300px;
    overflow: scroll;
}
.wrapper {
    position: absolute; 
    background-color: transparent; border: 2px solid black;
    width: 300px; height: 200px;
    top: 50%; left: 50%;
    transform: translate(-50%, -50%); 
    transition: all 0.5s;
}
.box {
    width: 300px; height: 200px;
    background: linear-gradient(to right, red, white);
    border: 2px solid yellow;
    transform-origin: top left;
    transition: all 0.5s;
}
.inner { width:50px; height: 50px; background-color: green; }
<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.0/jquery.min.js"></script>
<label for="slider1">Scale: </label>
<input id="slider1" type="range" min="0.5" max="3" value="1" step="0.25" list="datalist" onchange="scaleValue.value=value" />
<output for="slider1" id="scaleValue">1</output>
<datalist id="datalist">
 <option>0.5</option>
 <option>0.75</option>
 <option>1</option>
    <option>1.5</option>
    <option>2</option>
    <option>3</option>
</datalist>
<hr>
<div id="container">
    <div id="wrapper" class="wrapper">
        <div id="box" class="box">
            <div class="inner"></div>
        </div>
    </div>
</div>

All scripts tested with IE-11, GC-43, and FF-38

.

Abhitalks
  • 27,721
  • 5
  • 58
  • 81
  • i was able to solve it by separating the container div from the scaler div, this allows me to satisfy another goal which is zooming to a point (currently the zoom animation is weird in all solutions) however you were the closest. thanks for the clear summary, the bounty is yours. – Ahmed Kotb Jul 14 '15 at 18:47
  • Not to be a killjoy but none of these solutions scroll smoothly for me? – som Jul 14 '15 at 23:11
  • @AhmedKotb (or anyone else): I can't quite tell from your comment, but did you ever find a way to do this so that, with a scale > 1, the animation doesn't first zoom to the top left and then reposition with an animated scroll? – Adam Prescott Apr 04 '16 at 18:12
  • @AdamPrescott i had it working by having two divs completely separated from each other, lets call them the zoom div and the scrollable div, the first div is centered using negative margins and i do all the required math manually to do the zoom/animation, and after the animation ends i copy the width/height of the zoom div to a dummy div inside the scrollable div which gives me the native scrollbar. still i also need to handle onscroll to actually do translateX/Y on the zoom div. does that make since ? – Ahmed Kotb Apr 07 '16 at 23:15
3

The hard part of this problem is that, in the middle of the zoom in process, you need to change from a model (where you have still margins) to another model where you need to expand the size of the container.

Since this happens in the middle of the transform, it's difficult to handle with a single property transform

The way that I have found to solve this is to change the min-height and min-width properties. This will give the posibility to auto detect this point and handle it gracefully

I am keeping in JS only the basic functionality, everything else is done in CSS

function setZoom3() {
    var ele = document.getElementById("base");
    ele.className = "zoom3";
}

function setZoom1() {
    var ele = document.getElementById("base");
    ele.className = "";
}

function setZoom05() {
    var ele = document.getElementById("base");
    ele.className = "zoom05";
}
.container {
    width: 300px;
    height: 300px;
    overflow: scroll;
    position: relative;
}

#base {
    width: 300px;
    height: 300px;
    top: 0px;
    left: 0px;
    position: absolute;
    min-width: 300px;
    min-height: 300px;
    transition: min-width 5s, min-height 5s;
}

.inner {
    width: 200px;
    height: 200px;
    background-color: lightgreen;
    background-image: linear-gradient(-45deg, red 5%, yellow 5%, green 95%, blue 95%);
    top: 50%;
    left: 50%;
    position: absolute;
    transform: translate(-50%, -50%); 
    transform-origin: top left;
    transition: transform 5s;
}

#base.zoom3 {
    min-width: 600px;
    min-height: 600px;
}
.zoom3 .inner {
    transform: scale(3) translate(-50%, -50%);
}

.zoom05 .inner {
    transform: scale(0.5) translate(-50%, -50%);
}

.trick {
    width: 20px;
    height: 20px;
    background-color: red;
    position: absolute;
    
    transform: translate(0px);
}
<div class="container">
    <div id="base">
        <div class="inner"></div>
    </div>
</div>
<button onclick="setZoom3();">zoom 3</button>
<button onclick="setZoom1();">zoom 1</button>
<button onclick="setZoom05();">zoom 0.5</button>

The only issue left would be to set the scroll position, just in case you want it to keep in the center

vals
  • 61,425
  • 11
  • 89
  • 138
2

I've managed to solve it with a touch of logic. I've used GSAP for the animation and updates, but you could easily get it working with vanilla JS:

http://codepen.io/theprojectsomething/pen/JdZWLV

... Because of the need to scroll the parent div to keep the element centred (and no way to animate the scroll) you're not going to be able to get a smooth transition with CSS alone.

Note: Even without the scroll, a pure CSS transition seems to have trouble syncing (the offset, whether top/left, margin, or translate, is always catching up with the scale) ... this may be due to sub-pixel positioning? Someone else may be able to provide further insight here.

Full code from Codepen:

var $inner = document.querySelector('aside'),
    $outer = document.querySelector('main'),
    $anim = document.querySelector('[type="checkbox"]'),
    $range = document.querySelector('[type="range"]'),
    data = {
      width: $outer.clientWidth,
      value: 1
    };

$range.addEventListener('input', slide, false);

function slide () {
  $anim.checked ? animate(this.value) : transform(this.value);
}

function animate (value) {
  TweenLite.to(data, 0.4, {
    value: value,
    onUpdate: transform
  });
}

function transform (value) {
  if( !isNaN(value) ) data.value = value;
  
  var val = Math.sqrt(data.value),
      offset = Math.max(1 - val, 0)*0.5,
      scroll = (val - 1)*data.width*0.5;

  TweenLite.set($inner, {
    scale: val,
    x: offset*100 + "%",
    y: offset*100 + "%"
  });
  
  TweenLite.set($outer, {
    scrollLeft: scroll,
    scrollTop: scroll
  });
}

window.onresize = function (){
  data.width = $outer.clientWidth;
  transform(data.value);
};
main, aside:before, footer {
  position: absolute;
  top: 50%;
  left: 50%;
  -webkit-transform: translate(-50%, -50%);
      -ms-transform: translate(-50%, -50%);
          transform: translate(-50%, -50%);
}

main {
  width: 50vh;
  height: 50vh;
  min-width: 190px;
  min-height: 190px;
  background: black;
  overflow: scroll;
  box-sizing: border-box;
}

aside {
  position: relative;
  width: 100%;
  height: 100%;
  border: 1vh solid rgba(0, 0, 0, 0.5);
  background-image: -webkit-linear-gradient(top, yellow, red);
  background-image: linear-gradient(to bottom, yellow, red);
  box-sizing: border-box;
  -webkit-transform-origin: 0 0;
      -ms-transform-origin: 0 0;
          transform-origin: 0 0;
}
aside:before {
  content: "";
  background: black;
  border-radius: 50%;
  width: 10%;
  height: 10%;
  display: block;
  opacity: 0.5;
}

input {
  display: block;
  margin: 3em auto;
}
input:after {
  content: attr(name);
  color: white;
  text-transform: uppercase;
  position: relative;
  left: 2em;
  display: block;
  line-height: 0.9em;
}
<script src="//cdnjs.cloudflare.com/ajax/libs/gsap/1.16.1/TweenMax.min.js"></script>
<main>
  <aside></aside>
</main>
<footer>
  <input type="checkbox" name="animate" checked>
  <input type="range" min="0.1" step="0.005" max="3">
</footer>
som
  • 2,023
  • 30
  • 37
1

Matthew King's fiddle was close to being correct.
The only thing that needs fixing is the div's offset. If your inner div is exceeding the boundaries of the scrollable div (negative offset) you need to set the offset to 0. Else you want the div to be centered.

Centering is however not that trivial. You are zooming the div's background with css, hence you are not affecting the actual width and height of your div and need to parse the scale matrix to calculate it's backgrounds dimension.

This solution works for me: https://jsfiddle.net/6e2g6vzt/11/
(at least for FF on Ubuntu, did not test on other browsers yet)

It is basically just one function to set the new offset, you don't even have to call it manually due to this line of code

$("#centered").bind("transitionend webkitTransitionEnd oTransitionEnd MSTransitionEnd", setNewOffset);

which calls the setNewOffset function after the transformation is completed.
As you can see the image 'jumps' to it's correct position after the transformation, maybe you want to add a smooth effect to cover that but i just wanted to show you how to get the correct offset.

Have a look at jQuery's documentation to learn more about .offset()


Credits:
Thanks to Jim Jeffers for the nice callback after transistions
https://stackoverflow.com/a/9255507/3586288
Thanks to Lea Verou for the great regex to parse the scale matrix
https://stackoverflow.com/a/5604199/3586288

Community
  • 1
  • 1
Tim Hallyburton
  • 2,741
  • 1
  • 21
  • 29