creating-a-distorted-mask-effect-with-babylon.js-and-glsl

Learn the basics of GLSL while creating a distorted mask effect on images using Babylon.js.

maskeffect_feat

From our weekly sponsor: Design every part of your website with the brand new Divi Theme Builder. Try it for free.

Nowadays, it’s really hard to navigate the web and not run into some wonderful website that has some stunning effects that seem like black magic.

Well, many times that “black magic” is in fact WebGL, sometimes mixed with a bit of GLSL. You can find some really nice examples in this Awwwards roundup, but there are many more out there.

Recently, I stumbled upon the Waka Waka website, one of the latest works of Ben Mingo and Aristide Benoist, and the first thing I noticed was the hover effect on the images.

It was obvious that it’s WebGL, but my question was: “How did Aristide do that?”

Since I love to deconstruct WebGL stuff, I tried to replicate it, and in the end I’ve made it.

In this tutorial I’ll explain how to create an effect really similar to the one in the Waka Waka website using Microsoft’s BabylonJS library and some GLSL.

This is what we’ll do.

The setup

The first thing we have to do is create our scene; it will be very basic and will contain only a plane to which we’ll apply a custom ShaderMaterial.

I won’t cover how to setup a scene in BabylonJS, for that you can check its comprehensive documentation.

Here’s the code that you can copy and paste:

import { Engine } from "@babylonjs/core/Engines/engine";
import { Scene } from "@babylonjs/core/scene";
import { Vector3 } from "@babylonjs/core/Maths/math";
import { ArcRotateCamera } from "@babylonjs/core/Cameras/arcRotateCamera";
import { ShaderMaterial } from "@babylonjs/core/Materials/shaderMaterial";
import { Effect } from "@babylonjs/core/Materials/effect";
import { PlaneBuilder } from "@babylonjs/core/Meshes/Builders/planeBuilder";

class App {
  constructor() {
    this.canvas = null;
    this.engine = null;
    this.scene = null;
  }

  init() {
    this.setup();
    this.addListeners();
  }

  setup() {
    this.canvas = document.querySelector("#app");
    this.engine = new Engine(this.canvas, true, null, true);
    this.scene = new Scene(this.engine);

    // Adding the vertex and fragment shaders to the Babylon's ShaderStore
    Effect.ShadersStore["customVertexShader"] = require("./shader/vertex.glsl");
    Effect.ShadersStore[
      "customFragmentShader"
    ] = require("./shader/fragment.glsl");

    // Creating the shader material using the `custom` shaders we added to the ShaderStore
    const planeMaterial = new ShaderMaterial("PlaneMaterial", this.scene, {
      vertex: "custom",
      fragment: "custom",
      attributes: ["position", "normal", "uv"],
      uniforms: ["worldViewProjection"]
    });
    planeMaterial.backFaceCulling = false;

    // Creating a basic plane and adding the shader material to it
    const plane = new PlaneBuilder.CreatePlane(
      "Plane",
      { width: 1, height: 9 / 16 },
      this.scene
    );
    plane.scaling = new Vector3(7, 7, 1);
    plane.material = planeMaterial;

    // Camera
    const camera = new ArcRotateCamera(
      "Camera",
      -Math.PI / 2,
      Math.PI / 2,
      10,
      Vector3.Zero(),
      this.scene
    );

    this.engine.runRenderLoop(() => this.scene.render());
  }

  addListeners() {
    window.addEventListener("resize", () => this.engine.resize());
  }
}

const app = new App();
app.init();

As you can see, it’s not that different from other WebGL libraries like Three.js: it sets up a scene, a camera, and it starts the render loop (otherwise you wouldn’t see anything).

The material of the plane is a ShaderMaterial for which we’ll have to create its respective shader files.

// /src/shader/vertex.glsl

precision highp float;

// Attributes
attribute vec3 position;
attribute vec3 normal;
attribute vec2 uv;

// Uniforms
uniform mat4 worldViewProjection;

// Varyings
varying vec2 vUV;

void main(void) {
    gl_Position = worldViewProjection * vec4(position, 1.0);
    vUV = uv;
}
// /src/shader/fragment.glsl

precision highp float;

// Varyings
varying vec2 vUV;

void main() {
  vec3 color = vec3(vUV.x, vUV.y, 0.0);
  gl_FragColor = vec4(color, 1.0);
}

You can forget about the vertex shader since for the purpose of this tutorial we’ll work only on the fragment shader.

Here you can see it live:

Good, we’ve already written 80% of the JavaScript code we need for the purpose of this tutorial.

The logic

GLSL is cool, it allows you to create stunning effects that would be impossible to do with HTML, CSS and JS alone. It’s a completely different world, and if you’ve always done “web” stuff you’ll get confused at the beginning, because when working with GLSL you have to think in a completely different way to achieve any effect.

The logic behind the effect we want to achieve is pretty simple: we have two overlapping images, and the image that overlaps the other one has a mask applied to it.

Simple, but it doesn’t work like SVG masks for instance.

Adjusting the fragment shader

Before going any further we need to tweak the fragment shader a little bit.

As for now, it looks like this:

// /src/shader/fragment.glsl

precision highp float;

// Varyings
varying vec2 vUV;

void main() {
  vec3 color = vec3(vUV.x, vUV.y, 0.0);
  gl_FragColor = vec4(color, 1.0);
}

Here, we’re telling the shader to assign each pixel a color whose channels are determined by the value of the x coordinate for the Red channel and the y coordinate for the Green channel.

But we need to have the origin at the center of the plane, not the bottom-left corner. In order to do so we have to refactor the declaration of uv this way:

// /src/shader/fragment.glsl

precision highp float;

// Varyings
varying vec2 vUV;

void main() {
  vec2 uv = vUV - 0.5;
  vec3 color = vec3(uv.x, uv.y, 0.0);
  gl_FragColor = vec4(color, 1.0);
}

This simple change will result into the following:

This is becase we moved the origin from the bottom left corner to the center of the plane, so uv‘s values go from -0.5 to 0.5. Since you cannot assign negative values to RGB channels, the Red and Green channels fallback to 0.0 on the whole bottom left area.

Creating the mask

First, let’s change the color of the plane to complete black:

// /src/shader/fragment.glsl

precision highp float;

// Varyings
varying vec2 vUV;

void main() {
  vec2 uv = vUV - 0.5;
  vec3 color = vec3(0.0);
  gl_FragColor = vec4(color, 1.0);
}

Now let’s add a rectangle that we will use as the mask for the foreground image.

Add this code outside the main() function:

vec3 Rectangle(in vec2 size, in vec2 st, in vec2 p, in vec3 c) {
  float top = step(1. - (p.y   size.y), 1. - st.y);
  float right = step(1. - (p.x   size.x), 1. - st.x);
  float bottom = step(p.y, st.y);
  float left = step(p.x, st.x);
  return top * right * bottom * left * c;
}

(How to create shapes is beyond of the scope of this tutorial. For that, I suggest you to read this chapter of “The Book of Shaders”)

The Rectangle() function does exactly what its name says: it creates a rectangle based on the parameters we pass to it.

Then, we redeclare the color using that Rectangle() function:

vec2 maskSize = vec2(0.3, 0.3);

// Note that we're subtracting HALF of the width and height to position the rectangle at the center of the scene
vec2 maskPosition = vec2(-0.15, -0.15);
vec3 maskColor =  vec3(1.0);

color = Rectangle(maskSize, uv, maskPosition, maskColor);

Awesome! We now have our black plane with a beautiful white rectangle at the center.

But, wait! That’s not supposed to be a rectangle; we set its size to be 0.3 on both the width and the height!

That’s because of the ratio of our plane, but it can be easily fixed in two simple steps.

First, add this snippet to the JS file:

this.scene.registerBeforeRender(() => {
  plane.material.setFloat("uPlaneRatio", plane.scaling.x / plane.scaling.y);
});

And then, edit the shader by adding this line at the top of the file:

uniform float uPlaneRatio;

…and this line too, right below the line that sets the uv variable

uv.x *= uPlaneRatio;

Short explanation

In the JS file, we’re sending a uPlaneRatio uniform (one of the GLSL data type) to the fragment shader, whose value is the ratio between the plane width and height.

We made the fragment shader wait for that uniform by declaring it at the top of the file, then the shader uses it to adjust the uv.x value.


Here you can see the final result: a black plane with a white square at the center; nothing too fancy (yet), but it works!

Adding the foreground image

Displaying an image in GLSL is pretty simple. First, edit the JS code and add the following lines:

// Import the `Texture` module from BabylonJS at the top of the file
import { Texture } from '@babylonjs/core/Materials/Textures/texture'
// Add this After initializing both the plane mesh and its material
const frontTexture = new Texture('src/images/lantern.jpg')
plane.material.setTexture("u_frontTexture", frontTexture)

This way, we’re passing the foreground image to the fragment shader as a Texture element.

Now, add the following lines to the fragment shader:

// Put this at the beginninng of the file, outside of the `main()` function
uniform sampler2D u_frontTexture;
// Put this at the bottom of the `main()` function, right above `gl_FragColor = ...`
vec3 frontImage = texture2D(u_frontTexture, uv * 0.5   0.5).rgb;

A bit of explaining:

We told BabylonJS to pass the texture to the shader as a sampler2D with the setTexture() method, and then, we made the shader know that we will pass that sampler2D whose name is u_frontTexture.

Finally, we created a new variable of type vec3 named frontImage that contains the RGB values of our texture.

By default, a texture2D is a vec4 variable (it contains the r, g, b and a values), but we don’t need the alpha channel so we declare frontImage as a vec3 variable and explicitly get only the .rgb channels.

Please also note that we’ve modified the UVs of the texture by first multiplying it by 0.5 and then adding 0.5 to it. This is because at the beginning of the main() function I’ve remapped the coordinate system to -0.5 -> 0.5, and also because of the fact that we had to adjust the value of uv.x.


If you now add this to the GLSL code…

color = frontImage;

…you will see our image, rendered by a GLSL shader:

Masking

Always keep in mind that, for shaders, everything is a number (yes, even images), and that 0.0 means completely hidden while 1.0 stands for fully visible.

We can now use the mask we’ve just created to hide the parts of our image where the value of the mask equals 0.0.

With that in mind, it’s pretty easy to apply our mask. The only thing we have to do is multiply the color variable by the value of the mask:

// The mask should be a separate variable, not set as the `color` value
vec3 mask = Rectangle(maskSize, uv, maskPosition, maskColor);

// Some super magic trick
color = frontImage * mask;

Et voilà, we now have a fully functioning mask effect:

Let’s enhance it a bit by making the mask follow a circular path.

In order to do that we must go back to our JS file and add a couple of lines of code.

// Add this to the class constructor
this.time = 0
// This goes inside the `registerBeforeRender` callback
this.time  ;
plane.material.setFloat("u_time", this.time);

In the fragment shader, first declare the new uniform at the top of the file:

uniform float u_time;

Then, edit the declaration of maskPosition like this:

vec2 maskPosition = vec2(
  cos(u_time * 0.05) * 0.2 - 0.15,
  sin(u_time * 0.05) * 0.2 - 0.15
);

u_time is simply one of the uniforms that we pass to our shader from the WebGL program.

The only difference with the u_frontTexture uniform is that we increase its value on each render loop and pass its new value to the shader, so that it updates the mask’s position.

Here’s a live preview of the mask going in a circle:

Adding the background image

In order to add the background image we’ll do the exact opposite of what we did for the foreground image.

Let’s go one step at a time.

First, in the JS class, pass the shader the background image in the same way we did for the foreground image:

const backTexture = new Texture("src/images/lantern-bw.jpg");
plane.material.setTexture("u_backTexture", backTexture);

Then, tell the fragment shader that we’re passing it that u_backTexture and initialize another vec3 variable:

// This goes at the top of the file
uniform sampler2D backTexture;

// Add this after `vec3 frontImage = ...`
vec3 backgroundImage = texture2D(iChannel1, uv * 0.5   0.5).rgb;

When you do a quick test by replacing

color = frontImage * mask;

with

color = backImage * mask;

you’ll see the background image.

But for this one, we have to invert the mask to make it behave the opposite way.

Inverting a number is really easy, the formula is:

invertedNumber = 1 - 

So, let’s apply the inverted mask to the background image:

backImage *= (1.0 - mask);

Here, we’re applying the same mask we added to the foreground image, but since we inverted it, the effect is the opposite.

Putting it all together

At this point, we can refactor the declaration of the two images by directly applying their masks.

vec3 frontImage = texture2D(u_frontTexture, uv * 0.5   0.5).rgb * mask;
vec3 backImage = texture2D(u_backTexture, uv * 0.5   0.5).rgb * (1.0 - mask);

We can now display both images by adding backImage to frontImage:

color = backImage   frontImage;

That’s it, here’s a live example of the desired effect:

Distorting the mask

Cool uh? But it’s not over yet! Let’s tweak it a bit by distorting the mask.

To do so, we first have to create a new vec2 variable:

vec2 maskUV = vec2(
  uv.x   sin(u_time * 0.03) * sin(uv.y * 5.0) * 0.15,
  uv.y   cos(u_time * 0.03) * cos(uv.x * 10.0) * 0.15
);

Then, replace uv with maskUV in the mask declaration

vec3 mask = Rectangle(maskSize, maskUV, maskPosition, maskColor);

In maskUV, we’re using some math to add uv values based on the u_time uniform and the current uv.

Try tweaking those values by yourself to see different effects.

Distorting the foreground image

Let’s now distort the foreground image the same way we did for the mask, but with slightly different values.

Create a new vec2 variable to store the foreground image uvs:

vec2 frontImageUV = vec2(
  (uv.x   sin(u_time * 0.04) * sin(uv.y * 10.) * 0.03),
  (uv.y   sin(u_time * 0.03) * cos(uv.x * 15.) * 0.05)
);

Then, use that frontImageUV instead of the default uv when declaring frontImage:

vec3 frontImage = texture2D(u_frontTexture, frontImageUV * 0.5   0.5).rgb * mask;

Voilà! Now both the mask and the image have a distortion effect applied.

Again, try tweaking those numbers to see how the effect changes.

10 – Adding mouse control

What we’ve made so far is really cool, but we could make it even cooler by adding some mouse control like making it fade in/out when the mouse hovers/leaves the plane and making the mask follow the cursor.

Adding fade effects

In order to detect the mouseover/mouseleave events on a mesh and execute some code when those events occur we have to use BabylonJS’s actions.

Let’s start by importing some new modules:

import { ActionManager } from "@babylonjs/core/Actions/actionManager";
import { ExecuteCodeAction } from "@babylonjs/core/Actions/directActions";
import "@babylonjs/core/Culling/ray";

Then add this code after the creation of the plane:

this.plane.actionManager = new ActionManager(this.scene);

this.plane.actionManager.registerAction(
  new ExecuteCodeAction(ActionManager.OnPointerOverTrigger, () =>
    this.onPlaneHover()
  )
);

this.plane.actionManager.registerAction(
  new ExecuteCodeAction(ActionManager.OnPointerOutTrigger, () =>
    this.onPlaneLeave()
  )
);

Here we’re telling the plane’s ActionManager to listen for the PointerOver and PointerOut events and execute the onPlaneHover() and onPlaneLeave() methods, which we’ll add right now:

onPlaneHover() {
  console.log('hover')
}

onPlaneLeave() {
  console.log('leave')
}

Some notes about the code above

Please note that I’ve used this.plane instead of just plane; that’s because we’ll have to access it from within the mousemove event’s callback later, so I’ve refactored the code a bit.

ActionManager allows us to listen to certain events on a target, in this case the plane.

ExecuteCodeAction is a BabylonJS action that we’ll use to execute some arbitrary code.

ActionManager.OnPointerOverTrigger and ActionManager.OnPointerOutTrigger are the two events that we’re listening to on the plane. They behave exactly like the mouseenter and mouseleave events for DOM elements.

To detect hover events in WebGL, we need to “cast a ray” from the position of the mouse to the mesh we’re checking; if that ray, at some point, intersects with the mesh, it means that the mouse is hovering it. This is why we’re importing the @babylonjs/core/Culling/ray module; BabylonJS will take care of the rest.


Now, if you test it by hovering and leaving the mesh, you’ll see that it logs hover and leave.

Now, let’s add the fade effect. For this, I’ll use the GSAP library, which is the de-facto library for complex and high-performant animations.

First, install it:

yarn add gsap

Then, import it in our class

import gsap from 'gsap

and add this line to the constructor

this.maskVisibility = { value: 0 };

Finally, add this line to the registerBeforeRender()‘s callback function

this.plane.material.setFloat( "u_maskVisibility", this.maskVisibility.value);

This way, we’re sending the shader the current value property of this.maskVisibility as a new uniform called u_maskVisibility.

Refactor the fragment shader this way:

// Add this at the top of the file, like any other uniforms
uniform float u_maskVisibility;

// When declaring `maskColor`, replace `1.0` with the `u_maskVisibility` uniform
vec3 maskColor = vec3(u_maskVisibility);

If you now check the result, you’ll see that the foreground image is not visible anymore; what happened?

Do you remember when I wrote that “for shaders, everything is a number”? That’s the reason! The u_maskVisibility uniform equals 0.0, which means that the mask is invisible.

We can fix it in few lines of code. Open the JS code and refactor the onPlaneHover() and onPlaneLeave() methods this way:

onPlaneHover() {
  gsap.to(this.maskVisibility, {
    duration: 0.5,
    value: 1
  });
}

onPlaneLeave() {
  gsap.to(this.maskVisibility, {
    duration: 0.5,
    value: 0
  });
}

Now, when you hover or leave the plane, you’ll see that the mask fades in and out!

(And yes, BabylonJS has it’s own animation engine, but I’m way more confident with GSAP, that’s why I opted for it.)

Make the mask follow the mouse cursor

First, add this line to the constructor

this.maskPosition = { x: 0, y: 0 };

and this to the addListeners() method:

window.addEventListener("mousemove", () => {
  const pickResult = this.scene.pick(
    this.scene.pointerX,
    this.scene.pointerY
  );

  if (pickResult.hit) {
    const x = pickResult.pickedPoint.x / this.plane.scaling.x;
    const y = pickResult.pickedPoint.y / this.plane.scaling.y;

    this.maskPosition = { x, y };
  }
});

What the code above does is pretty simple: on every mousemove event it casts a ray with this.scene.pick() and updates the values of this.maskPosition if the ray is intersecting something.

(Since we have only a single mesh we can avoid checking what mesh is being hit by the ray.)

Again, on every render loop, we send the mask position to the shader, but this time as a vec2. First, import the Vector2 module together with Vector3

import { Vector2, Vector3 } from "@babylonjs/core/Maths/math";

Add this in the runRenderLoop callback function

this.plane.material.setVector2(
  "u_maskPosition",
  new Vector2(this.maskPosition.x, this.maskPosition.y)
);

Add the u_maskPosition uniform at the top of the fragment shader

uniform vec2 u_maskPosition;

Finally, refactor the maskPosition variable this way

vec3 maskPosition = vec2(
  u_maskPosition.x * uPlaneRatio - 0.15,
  u_maskPosition.y - 0.15
);

Side note; I’ve adjusted the x using the uPlaneRatio value because at the beginning of the main() function I did the same with the shader’s uvs

And here you can see the result of your hard work:

Conclusion

As you can see, doing these kind of things doesn’t involve too much code (~150 lines of JavaScript and ~50 lines of GLSL, including comments and empty lines); the hard part with WebGL is the fact that it’s complex by nature, and it’s a very vast subject, so vast that many times I don’t even know what to search on Google when I get stuck.

Also, you have to study a lot, way more than with “standard” website development. But in the end, it’s really fun to work with.

In this tutorial, I tried to explain the whole process (and the reasoning behind everything) step by step, just like I want someone to explain it to me; if you’ve reached this point of this tutorial, it means that I’ve reached my goal.

In any case, thanks!

Credits

The lantern image is by Vladimir Fetodov

visually-distorted-–-when-symmetrical-ui-looks-all-wrong

Visually distorted - when symmetrical UI looks all wrong - the mobile spoon

I’ve been gifted with a questionable super-power to spot visual defects at first glance.


It’s like having a spider-sense for distortions, I get this tingling feeling in the back of my skull, every time I see something that’s not aligned, twisted or unaesthetic.

It happens to me when I meet people (look at those gigantic hands! her head is tiny! OMG those fingernails! they look like they belong to a mole!), and as much as I’m trying to make it stop – I just can’t fight my own super-powers.

It also happens when I look at user interfaces: whether I’m working on something new, advising others, or just using a product – I can’t help but spotting design issues the minute I look at things.

So, as an attempt to get rid of this overweight – I decided to create this collection of common UI distortions caused by optical illusions and other design reasons, along with my proposed fixes, hoping that it will help the world create better-looking interfaces (and help me get rid of this unwanted “gift”).

Enjoy the freak show!


(I know I won’t)

1. This label is too light  

Ever noticed how the color of your text seems lighter when placed next to a filled shape? 

That’s the font smoothing effect that makes the text feel lighter than its’ original color, and the smaller the font is – the stronger this visual effect becomes.

Here’s an example: 

Visually distorted - font smoothing makes some text elements seem lighter than their actual color

On the left – the green label seems to be lighter than the green button. 

On the right – by cheating a little bit and using a slightly darker green color, this problem is magically solved.

2. Tiny fonts seem too skinny

In certain font families, using a small size causes the text to feel skinny and semi-transparent (font smoothing again).

If you insist on using tiny font size (sometimes it makes sense) – do yourself a favor and make it bold:

Visually distorted - using small font size makes the text feel semi-transparent. Use bold fonts to fix this problem.

By using a bold font – the design stays clean, the label remains tiny but more noticeable and definitely easier to read.

3. Unreadable text over a background image 

Placing a text over a background image is a common pattern, but if the images change dynamically – you need a way to ensure that the text will remain readable regardless of the background color.

This can be achieved by ensuring adequate contrast with a gradient back color:

Visually distorted - achieving text readability by placing a gradient layer below the text.

A semi-transparent gradient layer is a nice method to make sure the text remains readable even if it’s placed over a light-colored background image.  

4. Wrong leading space 

When dealing with large fonts, the leading space might become a bit of an issue: 

Visually distorted - A good rule of thumb is to set the leading space to be 2pt-5pt larger than the type size, depending on the typeface

Dealing with shapes:  

Placing different shapes next to one another can cause some unexpected optical illusions

The first 2 shapes feel misaligned, although they are perfectly aligned. The 2 arrows feel like they have different lengths although the lines are absolutely identical (I know, I copied/pasted them!).

The first 2 shapes feel misaligned, although they are perfectly aligned. 

The 2 arrows feel like they have different lengths although the lines are absolutely identical (I know, I copied/pasted them!). 

Sometimes those optical illusions can make your design feel inaccurate, which is a bummer given that you worked so hard to make it 100% accurate.

In such cases, you may have to cheat a little bit, nudge some elements around, make one of the lines longer than the other – do whatever it takes to eliminate the optical illusion and make things look aligned: 

In such cases, you may have to cheat a little bit, nudge some elements around, make one of the lines longer than the other - do whatever it takes to eliminate the optical illusion and make things look aligned:

Let’s dive into some real-life UI examples:

5. Misaligned elements

This one is a classic: 

Visually distorted - rounded button looks misaligned with the text. Use overshooting to fix this optical illusion.

On the left – an optical misalignment effect, caused by the rounded edges of the button. 

On the right – the fix for this problem: a technique called ‘Overshooting’ (often used in fonts) that fixes the misalignment illusion by nudging the button a little bit to the left.

6. Inconsistent form alignment

When a form uses different elements (with different shapes, borders, or horizontal alignments) – an optical illusion is likely to happen and make the form feel a bit misaligned:

Visually distorted - Once the elements look more consistent, the left alignment works better and the entire form feels cleaner and well-designed.

On the left – every element has a slightly different design (some have rounded borders, some don’t) and although there is a clear alignment strategy – the result feels all twisted.

There are many possible fixes – I chose to turn all rounded corners into sharp corners and align all the labels to the borders.

Working with icons

Ever felt like the icons you’re using are misaligned?  

Here are a few icons that are all coming from the same collection and have the exact same size and alignment, and yet, due to the nature (and optical weight) of each icon, the collection doesn’t fee very much aligned:

Visually distorted - Here are a few icons that are all coming from the same collection and have the exact same size and alignment, and yet, due to the nature (and optical weight) of each icon, the collection doesn’t fee very much aligned:

7. Misaligned icons 

Sometimes you just need to cheat a little and move/resize some of your icons in order to make them feel aligned: 

Visually distorted - different icons may require some manual fixes in order to create an optical alignment when placed side by side

On the left – some icons feel too big (phone, radio), while some icons feel misaligned (hands) due to their different designs and physical centers. 

One optional fix is to select icons that share similar shapes so they look more consistent (for example a set of circle-shaped icons). 

Another solution (presented above) is to slightly modify each of the icons so they work better when placed side by side.

Yes, it means you may need to place the icons in your actual design, modify some of them and repeat this experiment a few times until the design looks perfect.

8. Asymmetric shapes

When working with asymmetric shapes like triangles, the geometrical center causes an optical illusion that makes it feel like the shape is misplaced.

Here’s a classic example of the ‘play’ button: 

Visually distorted - nudge your asymmetric object until you reach an optical alignment.

While the left example is perfectly centered (geometrically speaking), the right example looks better.

The solution is to nudge your asymmetric object until you reach an optical alignment. 

9. Different icon themes

If you are using ready-made icons, it’s important to select icons that feel like they were taken from the same collection pack. 

Often enough, we get caught in a long exploratory process, searching for the best looking icons, and don’t pay enough attention to visual differences between each one.

Here’s what can happen:

Visually distorted - make sure to select icons that share the same color palette, theme shape, weight, and line width.

As you can see from the above example: using icons from different collections (different shapes, different weights) makes the UI feel unprofessional. 

Users may not be experienced enough to say what’s wrong, but they will notice something is wrong… 

Make sure to select icons that share the same color palette, theme shape, weight, and line width.

10. Multiline button labels  

There are times where design wins over copy. 

Here’s an example: 

Visually distorted - sometimes the copy should be changed to fit the design constraints

Sure, “add to favorites” explains the action of the button better than just “favorite”, but having 2 lines of text below the button is just unacceptable.

Not only it looks misaligned, but it is also likely to complicate any content that will be placed below these buttons. 

When such things happen, the design should win and the copy should be changed to fit the design constraints: either you change the labels of all the buttons to fit 2 lines or you shorten the “add to favorites” label to be “favorite”.

11. Long copy

UX writing should be part of the design process. Some design considerations will dictate the text length and some text considerations will dictate the design. 

As a rule of thumb, the text should concise: 

Writing is easy, all you have to do is cross out the wrong words…

As they say: “writing is easy, all you have to do is cross out the wrong words…”

12. Tap target is too small

12 years later, and designers (and developers) are still creating buttons that are too small to tap:

Visually distorted - 12 years later, and designers (and developers) are still creating buttons that are too small to tap:

According to Nielsen Norman: Interactive elements must be at least 1cm × 1cm (0.4in × 0.4in) to support adequate selection time and prevent fat-finger errors.

Apple said it 12 years ago, but it’s still pretty easy to find buttons that are not easily tapped. 

The fix is obvious: the button size should be larger than the visual content size (be it an icon or a text).

Check out the best of the mobile spoon


13. Annoying border radiuses 

Visually distorted - Jastrow illusion (AKA: Boomerang illusion)


Sure, the radius of the inner shape looks bigger, but that’s only an optical illusion. In fact, both radiuses are identical, but the eye just doesn’t catch it.


The same thing happens when you place a rounded button inside a rounded frame:

Visually distorted - solving the Jastrow illusion by setting the frame radius to be completely different than the button

In the example above, the frame and the button both have the same rounded edges but they look inconsistent.


The “cheat” in this case is to use a completely different corner radius for the frame which eliminates the optical illusion.

14. Annoying borders (in general)

And speaking of borders – too many lines make your design look crowded.


Designers talk a lot about white spaces, but often enough, as the product evolves and new features are added (sometimes without going through a proper design process), frames and borders are mysteriously added: 

Visually distorted - too many frames and borders make your UI feel cluttered

Get rid of the borders and use whitespaces instead to separate each group of elements without creating unnecessary clutter.

15. Using Gray instead of opacity 

Many designs use different shades of gray to create a hierarchy between titles, subtitles, and body text. 

Unfortunately, when text is placed over colorful elements (for example background images) – the gray no longer works, and instead – white color with certain transparency should be used to let the elements absorb the background colors: 

Visually distorted - use white color with transparency instead of using gray colors.

Using transparency instead of pure Gray colors will make your semi-transparent elements combine with the background colors and feel more natural.

It’s a small design hack that can result in much better results.

Bonus track: the annoying dark mode

So I actually wrote this post a few months ago, and since writing it, iOS 13 was released and Dark Mode was added with a lot of smoke and mirrors.

Apple claims it’s “thoughtfully designed to make every element on the screen easier on your eyes”, but if you used dark mode for a while you probably noticed this is not the case. 

There’s a reason for that: iOS dark mode uses pure black as the background, and pure black causes eye strain.

According to UX Movement: “White has 100% color brightness and black has 0% color brightness. Such a big contrast in color brightness leads to a disparity in the light levels users see. This causes their eyes to work harder to adapt to the brightness when they read.”

The solution is to use dark gray instead of using pure black:

Visually distorted - pure black and white create high contrast which is hard to get used to. Use dark gray instead.

Unfortunately, Apple default apps and most apps I’ve tried so far are all using pure black as a background, causing overstimulation.

2 apps that implemented dark mode using a dark gray background are Slack and Notion, so hopefully more apps will adopt this alternative and create dark themes that are actually easier on the eyes. 

That’s it for today’s collection.

Make sure to subscribe to my occasional newsletter and become 23% more awesome than average!