Search code examples
cssrotationcss-transformsskewdistortion

css3 from squared image to trapezoid without x rotation


I'm trying to transform a square div with a background image to a trapezoid.

I would like to make it in 2D, pretty much the same way the "Distort" tool of Photoshop does.

Basically, all I want is to narrow the top side of the square and get the image to be deformed accordingly.

The 3D transformation "seems" to do the trick:

transform: rotateX(30deg);

It works for most use cases but not all of them. Indeed, It is a 30deg rotation of the square that "looks" like a trapezoid when seen from the front/back side but is remains a 30° rotated square when seen from any other side.

What I want is to get an actual trapezoid. I want the squared image to be distorted in a 2D way so that the shape and image are actually changed, with no rotation involved.

I tried this and it worked in terms of shape (trapezoid):

border-style: solid;
height: 0;
border-color: transparent transparent red transparent;
border-width: 0 100px 100px 100px;

But then I can't replace the red area with a background-image that would follow the distortion. Which defeats my purpose. Any attempt I tried gets the picture to remain undeformed.

Is there any css/html5/javascript trick that would achieve what I want?

Thanks.


Solution

  • You can get the effect by applying a 3D transform on a pseudo-element (on which you also set the background-image) and making sure it's flattened in its original plane - that of its parent. This means that if you want to rotate something in 3D, you have to rotate the parent.

    Step #1: create a square div, add a pseudo (or a child) that has the exact same dimensions and set the background-image on this pseudo.

    div {
        display: grid; /* makes pseudo stretch all across */
        width: 28em; /* whatever edge value we want */
        aspect-ratio: 1; /* make it square */
        /* just to highlight div boundaries */
        box-shadow: 0 0 0 3px;
        
        &::after {
            background: url(image.jpg) 50%/ cover;
            content: ''
        }
    }
    

    Step #2: set the transform-origin on the pseudo to the middle of the bottom edge (100% 50%) - this ensures the bottom edge will remain in place after applying the 3D transform.

    Step #3: apply a 3D skew along the z axis lengthening the edge along the y axis.

    Yes, we don't have 3D skew functions in CSS. But we have matrix3d(), which can be used to express any rotation, scale, skew, translation!

    So let's first understand how skewing works.

    Skewing happens along an axis.

    Here's an interactive demo illustrating how the 2D skew functions work.

    Consider this example, where we skew along the x axis and the edge along the y axis gets lengthened as the y axis rotates away from its initial position - this angle is the skew angle. The z axis is perpendicular onto the plane in which we skew (xOy in this example) and is unaffected:

    skewX example

    Well, in our case, we do something similar, but the skew happens in the yOz plane, not in the xOy plane, as we skew along the z axis instead of along the x axis.

    the system of coordinates

    Since we've anchored the middle of the bottom edge of our pseudo in place with transform-origin and this skew happens along the z axis (perpendicular onto the screen), it results we're basically pulling and stretching our pseudo back, towards the back of the screen, preserving the x and y coordinates of every point, but changing the z coordinates.

    Basically, it would look like below if we were to view it in 3D without flattening into the parent's plane (the parent is bounded by the outline).

    3D view at result post z-skew with no flattening into parent's plane

    You can see how the horizontal guidelines at the top show how the top of the skewed pseudo has preserved its x and y coordinates, it just got pulled back along the z axis.

    Alright, how do we CSS this?

    As mentioned, there's no 3D skew, but we can build our transform matrix ourselves. Since this is a skew along the z axis (3rd axis) stretching the edge along the y axis (2nd axis), the only position in our matrix different from the unit matrix (1 along the main diagonal, 0 elsewhere) is going to be on the 3rd row, 2nd column. And we're going to have the tangent of the skew angle there. On MDN, you can see this for skewX() and skewY() too.

    This is because every point along the skew axis gets displaced by its coordinate along the lengthening axis times the tangent of the skew angle - you can see this in the first illustration if you draw parallels to the axes (x axis, y axis pre- and post-skew) through the example point in its original position (in grey) and final position (in black). Drawing these parallels creates a right triangle where the x displacement over the y coordinate is the tangent of the skew angle.

    Okay, back to the matrix, it looks like this.

    1   0    0
    0   1    0
    0 tan(a) 1
    

    To get the matrix3d() values, we add one more row and one more column identical to what they'd be in a 4x4 unit matrix and then just list the values column by column (not row by row!). So far, we have:

    @use 'sass:math'; // allows us to use trigonometric functions
    $a: 60deg; // the skew angle
    
    div {
        display: grid;
        width: 28em;
        aspect-ratio: 1;
        perspective: 25em;
        box-shadow: 0 0 0 3px;
        
        &::after {
            transform-origin: 50% 100%;
            transform: matrix3d(1, 0, 0, 0, /* 1st column */
                                0, 1, math.tan($a), 0, /* 2nd column */
                                0, 0, 1, 0, /* 3rd column */
                                0, 0, 0, 1);
            background: url(image.jpg) 50%/ cover;
            content: ''
        }
    }
    

    Note we've also added a perspective to get the distorted view (smaller at the top/ further back).

    The code so far gives us the flattened version of what we can see in the gif above. And I say the flattened version because, with what we have here, the pseudo always gets flattened in the plane of its parent.

    When the parent div has no 3D transform, we look at it from the front and the pseudo obviously looks flattened.

    When the parent div does have a 3D transform, its 3D-transformed pseudo gets flattened into its plane because the default transform-style value is flat. This means that any 3D-transformed children/ pseudos of a 3D transformed parent get flattened in the plane of the parent. This can be changed if we set the div's transform-style to preserve-3d. But we don't want that here.

    Step 4: fix the top edge!

    There's just one more thing that still doesn't look right: the post-transform top edge is now below the original one.

    post-transform result

    This is because we've set a perspective and how this works. By default, the perspective-origin is dead in the middle of the element we set it on (in this case our div), at 50% horizontally and 50% vertically.

    Let's consider just the points behind the plane of the screen because that's where our entire 3D-skewed pseudo is.

    With the default perspective-origin (50% 50%), only the points on the line perpendicular onto the plane of the screen in the very middle of our div are going to be projected onto the screen plane at a point with the same x,y coordinates as their own after taking into account perspective. Only the points in the plane perpendicular onto the screen and intersecting the screen along the horizontal midline of the div are going to be projected onto this horizontal midline after taking into account perspective.

    Do you see where this is going? If we move the perspective-origin so that it's in the middle of the div's top edge (50% 0), then the points in the plane perpendicular onto the screen along this top edge are going to be projected along this top edge - that is, the top edge of the 3D-skewed pseudo will be along the same line as its parent's top edge.

    So our final code is:

    @use 'sass:math'; // allows us to use trigonometric functions
    $a: 60deg; // the skew angle
    
    div {
        display: grid;
        width: 28em;
        aspect-ratio: 1;
        perspective-origin: 50% 0;
        perspective: 25em;
        box-shadow: 0 0 0 3px;
        
        &::after {
            transform-origin: 50% 100%;
            transform: matrix3d(1, 0, 0, 0, /* 1st column */
                                0, 1, math.tan($a), 0, /* 2nd column */
                                0, 0, 1, 0, /* 3rd column */
                                0, 0, 0, 1);
            background: url(image.jpg) 50%/ cover;
            content: ''
        }
    }
    

    Here is a live comparative view between our result and its pre-transform version as both divs rotate in 3D to show they're flat in the xOy plane.

    comparative view animation


    Don't want to use a preprocessor for the tangent value? Firefox and Safari support trigonometric functions by default already and Chrome 111+ supports them with the Experimental Web Platform features flag enabled in chrome://flags.

    Edit: trigonometric functions in CSS now work cross-browser.

    Don't want to wait for Chromium support either? You don't even need to use a tangent computation there, you can use any positive number - the bigger this number gets, the smaller the top edge gets. I used the tangent value to illustrate where it comes from, but you don't have to. Our tangent values are computed for angles from to 90°. This gives us tangent values from 0 to +Infinity. So yeah, any positive number will do there in the matrix.