Tutorials Books Videos Forums

Change the theme! Search!
Rambo ftw!

Customize Theme


Color

Background


Done

Drawing Sharp Lines on the Canvas

by kirupa   |   filed under Working with the Canvas

Ensure crisp and sharp lines by understanding the relationship between line position and line thickness/width.

Earlier, we looked at how to draw a perfect grid. We even ensured that the visuals would be crisp on our displays by accounting for DPI. All of this makes for something pretty sweet-looking:

When dealing specifically with horizontal or vertical lines, an added subtle detail becomes important if we want to ensure those lines remain sharp and crisp. In this article, we will look into that subtle detail and more.

Onwards!

Specifying the Correct Position Values

For the best and sharpest results when drawing lines, the conventional wisdom is that we should ensure all our lines are positioned on integer values. As it turns out, that isn’t always the case. Whether we position our lines on integer values or not is summarized by the following two rules:

The reason the rules are what they are has to do with how the canvas draws lines. Lines are positioned along the center of their coordinates, not at the edge. Because lines have a thickness, pixels are filled up to the thickness size in both directions perpendicular to the line's direction. For a horizontal line, this can be visualized as follows:

For a vertical line, our visual would be rotated by 90 degrees with the x-position determining where our line is located:

This combination of line position and line thickness determines whether the canvas draws our lines sharply or blurrily.

Now, this all may sound a bit abstract, so let's say that we are drawing a horizontal line at a vertical position of 25 pixels. Our goal is to ensure this line stays sharp and crisp across all line thickness values.

For a 1px-width line at position 25, given how our canvas would draw this line, our line will be positioned at the 25-pixel position. Because it has a thickness of 1 pixel, this line will expand 0.5px on either side of our 25-pixel position:

Because our line spreads outwards by the 0.5px, its ending point will be at 24.5px at the top and 25.5px at the bottom. Because this line visually ends at a half-pixel position, it will appear blurry as our canvas rendering engine tries to round up to the nearest pixel using antialiasing:

Knowing this, what if we position our 1px-width line at a half-pixel coordinate of 25.5? This will look as follows:

Notice that our 1px-width line starts at the 25.5-pixel y-position, and it spreads outward by 0.5px to end on pixel positions 25 and 26. Because no part of this line ends on a half-pixel coordinate, this line will look very sharp. No antialiasing shenanigans are needed. This is why all lines whose widths (or thicknesses) are odd-valued should specify a half-pixel coordinate value for their position.

Things are different for 2px lines (and other even-numbered width/thickness lines). These lines need to be positioned at a whole number coordinate:

Our line starts off at the 25-pixel y-position, and it grows outward by 1 pixel in each direction. The end result works nicely because the center of the line is on a whole pixel value and it spreads outwards by 1px in each direction to end on another pair of whole pixel values. There is nary a half-pixel value in sight, so the line appears very sharp. If we positioned our even-numbered width lines on a half-pixel value, this wouldn't work.

Drawing Lines using Rectangles

There is another way to draw lines, and that is by using a rectangle. To our eyes, a rectangle that is 1px in height is quite similar to a line that has a 1px thickness. To our canvas rendering engine, there is a big difference. Rectangles aren't drawn center-outward like lines are, so they are consistently predictable.

If you use a rectangle to draw lines, to ensure they look sharp, always make sure to position them on whole pixel values. Check out this example for a simple implementation.

 

A Conclusive Example

To see all of the above words and visuals put into code, we have a live example (open in new window) that you and I can play with:

What we see are two pairs of canvas examples:

  1. In Example 1, we position a 1px-width line on a whole coordinate value. In Example 2, we position this this same 1-px width line on a half-pixel coordinate value. Reinforcing our rules, the position at the half-pixel coordinate value gives us the sharpest result.
  2. In Example 3, our line is positioned on a whole coordinate value, and our line is positioned on a half-pixel coordinate value in Example 4. As expected, for our line with an even-numbered width/thickness value, the line drawn at the whole coordinate value looks the sharpest.

To see how to build this example yourself, create a new HTML document and add the following content to it:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Canvas Line Rendering Demo</title>
  <style>
    body {
      font-family: monospace;
      line-height: 1.6;
      padding: 20px;
      max-width: 900px;
      margin: 0 auto;
    }
    
    .container {
      display: flex;
      flex-direction: column;
      gap: 32px;
      padding: 16px;
      background-color: #f9fafb;
      border-radius: 8px;
    }
    
    .header {
      font-size: 24px;
      font-weight: 800;
    }
    
    .grid {
      display: grid;
      grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
      gap: 24px;
    }
    
    .example {
      padding: 8px;
      background-color: white;
      border: 1px solid #e5e7eb;
      border-radius: 4px;
    }
    
    .example-title {
      font-size: 14px;
      font-weight: 500;
      margin-bottom: 4px;
    }
    
    canvas {
      background-color: white;
      border: 1px solid #e5e7eb;
      width: 100%;
      height: auto;
    }
    
    .explanation {
      font-size: 14px;
      background-color: #eff6ff;
      padding: 12px;
      border-radius: 4px;
      border: 1px solid #dbeafe;
    }
    
    .explanation-title {
      font-weight: 600;
      margin-bottom: 8px;
    }
    
    .explanation p {
      margin: 0 0 8px 0;
    }

    .examplehighlight {
      background-color: #fff86c;
      border-radius: 10px;
      padding: 5px;
    }
  </style>
</head>
<body>
  <div class="container">
    <div class="header">Canvas Line Rendering Comparison</div>
    
    <div class="grid">
      <div class="example">
        <div class="example-title">Example 1: 1px-width line at <span class="examplehighlight">y=25</span></div>
        <canvas id="canvas1" width="200" height="60"></canvas>
      </div>
      
      <div class="example">
        <div class="example-title">Example 2: 1px-width line at <span class="examplehighlight">y=25.5</span></div>
        <canvas id="canvas2" width="200" height="60"></canvas>
      </div>
      
      <div class="example">
        <div class="example-title">Example 3: 2px-width line at <span class="examplehighlight">y=25</span></div>
        <canvas id="canvas3" width="200" height="60"></canvas>
      </div>
      
      <div class="example">
        <div class="example-title">Example 4: 2px-width line at <span class="examplehighlight">y=25.5</span></div>
        <canvas id="canvas4" width="200" height="60"></canvas>
      </div>
    </div>
  </div>

  <script>
    // Draw 1px lines at whole coordinates
    const canvas1 = document.querySelector('#canvas1');
    const ctx1 = canvas1.getContext('2d');
    ctx1.beginPath();
    ctx1.moveTo(50, 25);
    ctx1.lineTo(150, 25);
    ctx1.lineWidth = 1;
    ctx1.strokeStyle = 'black';
    ctx1.stroke();
    
    // Draw 1px lines at half-pixel coordinates
    const canvas2 = document.querySelector('#canvas2');
    const ctx2 = canvas2.getContext('2d');
    ctx2.beginPath();
    ctx2.moveTo(50, 25.5);
    ctx2.lineTo(150, 25.5);
    ctx2.lineWidth = 1;
    ctx2.strokeStyle = 'black';
    ctx2.stroke();
    
    // Draw 2px lines at whole coordinates
    const canvas3 = document.querySelector('#canvas3');
    const ctx3 = canvas3.getContext('2d');
    ctx3.beginPath();
    ctx3.moveTo(50, 25);
    ctx3.lineTo(150, 25);
    ctx3.lineWidth = 2;
    ctx3.strokeStyle = 'black';
    ctx3.stroke();
    
    // Draw 2px lines at half-pixel coordinates
    const canvas4 = document.querySelector('#canvas4');
    const ctx4 = canvas4.getContext('2d');
    ctx4.beginPath();
    ctx4.moveTo(50, 25.5);
    ctx4.lineTo(150, 25.5);
    ctx4.lineWidth = 2;
    ctx4.strokeStyle = 'black';
    ctx4.stroke();
  </script>
</body>
</html>

If you look at our code, there is nothing too fancy going on. The basic Line Drawing techniques are highlighted, with the only exotic detail being the vertical position of each line being set to a whole coordinate value or a half-pixel coordinate value depending on the line thickness.

Odds and Ends

There are two more tidbits to highlight before we call it a day:

  1. The above code and example do not have our DPI awareness logic. This accentuates the blurriness when the line is mispositioned. When we add the DPI awareness logic back in, the blurriness is more subtle. This definitely doesn't mean we can ignore the position of our lines when DPI awareness is in. We'll need to do both for the best results.
  2. When drawing lines that aren't horizontal or vertical, there are other techniques we can employ to ensure our lines are sharp and not anti-aliased. One approach is by using a technique developed by Jack Bresenham, and we'll look into that in the future.

Conclusion

Our friendly canvas is an immediate mode system. This means we are responsible for all aspects of getting pixels drawn on the screen. To do this effectively, we must be aware of its various drawing quirks and adjust our code accordingly. What we saw here around ensuring our lines stay sharp by accounting for position and line width/thickness is just one among the many details that we'll become very accustomed to as we get increasingly more proficient in using the canvas.

Just a final word before we wrap up. What you've seen here is freshly baked content without added preservatives, artificial intelligence, ads, and algorithm-driven doodads. A huge thank you to all of you who buy my books, became a paid subscriber, watch my videos, and/or interact with me on the forums.

Your support keeps this site going! 😇

Kirupa's signature!

The KIRUPA Newsletter

Thought provoking content that lives at the intersection of design 🎨, development 🤖, and business 💰 - delivered weekly to over a bazillion subscribers!

SUBSCRIBE NOW

Creating engaging and entertaining content for designers and developers since 1998.

Follow:

Popular

Loose Ends

:: Copyright KIRUPA 2025 //--