Use SASS to make dotted borders without weird corners.

CSS has dotted and dashed borders. They’re fine but don’t always look the way you might expect or prefer.

  • border-style: dotted dots are round (on my site I wanted square)
  • border-style: dashed dashes are nice and sharp but on corners the dashes become L shapes
  • Both dots and dashes are drawn inconsistently depending on how thick they are.

9-Slice Scaling

9-slice scaling is a technique for defining how images scale that’s been around since at least the days of Macromedia Flash. It’s typically used when we want to scale a border and avoid the ugly distortion we’d encounter if we were to just naively stretch an image.

An image is sliced up into 9 pieces, each piece being used to draw a specific edge or corner of a border. The top, bottom, left and right edges stretch (or repeat) to fit the content, but the corners are not scaled so they don’t distort. When it’s all put together we get a nice clean border.

9-slicing was a common way of achieving rounded corners on the web before we had border-radius. In those days it required assembling all of the image slices into a <table> or similar, then using that element as a background for the thing we wanted bordered. Hacky, but it worked and if you wanted rounded corners that didn’t look like hockey sticks before 2015, it’s what you did.

CSS border-image

CSS now has border-image which lets us use the 9-slice method properly. It depends on a specific, static image though, meaning if we want borders of different colours we have to create a separate image for each color. Particularly annoying while we’re experimenting with different colours.

SASS can Change the Fill Colour of an SVG

We can get around this by using an SVG image and encoding it as a Data URI (ie url("data:image/svg...")), only instead of the usual fill colour inside the SVG, we use placeholder text eg FILLCOLOR. We can then use SASS to find/replace that string with whichever colour we want.

String replacement

SASS doesn’t directly have a string replacement function but it’s simple to add one. I found one from CSS Tricks (I had to update it a little because SASS string function names have changed since it was written).

As you can see SASS has string.index(), which is just like String.prototype.lastIndexOf() in JavaScript (it gives us the character position/index of the matching string). SASS also has string.length(), so getting from those two functions to a str-replace is straightforward.

@use "sass:string";

/// Replace `$search` with `$replace` in `$string`
/// @author Kitty Giraudel
@function str-replace($string, $search, $replace: "") {
  $index: string.index($string, $search);

  @if $index {
    $string: string.slice($string, 1, $index - 1) +
      $replace +
      str-replace(
        string.slice($string, $index + string.length($search)),
        $search, $replace
      );
  }

  @return $string;
}

Getting the coloured image

We’ll stick to hex colours, because the URI encoding for those is really simple: the # just has to be converted to %23, so eg #ed397d becomes %23ed397d.

The SVG data is stored in a SASS variable $dotted-border-image-enc.

Using the str-replace function from above we can create a border-image-colored function which takes a hex colour, inserts it into $dotted-border-image-enc, and returns a url("data:image/svg...") which can be used as our border-image-source in CSS.

// SVG string, note the `style='fill:FILLCOLOR;`
$dotted-border-image-enc: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='90' height='90'%3E%3Cg style='fill:FILLCOLOR'%3E%3Cpath d='M10 10h10v10H10zM70 10h10v10H70zM40 40h10v10H40zM40 10h10v10H40zM10 70h10v10H10zM40 70h10v10H40zM70 70h10v10H70zM70 40h10v10H70zM10 40h10v10H10z'/%3E%3C/g%3E%3C/svg%3E";

@function border-image-colored($color_hex) {
  // Replace hex colour with url-encoded colour
  $enc-color: str-replace("" + $color_hex, "#", "%23");
  $img-url: str-replace(
    $dotted-border-image-enc,
    "FILLCOLOR",
    $enc-color
  );
  @return url($img-url);
}

Getting our sliced image border

Now we have a coloured image we can slice it up and apply it to borders. We’ll make a SASS @mixin that we can re-use wherever we want a dotted border.

The CSS border-image-* properties interact with each other in ways which can be confusing. I’ve used values here that worked with my image but check the MDN docs for border-image and play around.

A solid border is drawn for a split second while the border image loads. To prevent a flicker, we set border-color to match the background.

@mixin dotted-border($color_hex) {
  border-style: solid;
  border-width: 30px;
  border-image-width: 30px;
  border-image-slice: 30;
  border-image-repeat: round;

  // Prevent flash of foreground colour by setting the initial colour to match the page background
  border-color: white;

  // Call the function to get the SVG Data URI
  border-image-source: border-image-colored(#{$color_hex});
}

Allowing for optional border sides

Sometimes we might want to only show the border on specific sides of its element so we’ll give the mixin a $sides... argument. This means “take all remaining arguments (after $color_hex) and make them members of a list called $sides”. If $sides is not provided we’ll draw the full border.

It might look more complicated but the main difference is instead of setting eg border-style: solid as a whole, we set border-right-style: solid and so on, depending on which sides we want to draw.

@mixin dotted-border($color_hex, $sides...) {
  // biw = border image width
  $biw_top: "initial";
  $biw_left: "initial";
  $biw_right: "initial";
  $biw_bottom: "initial";
  
  $border_width: 30px;
  $border_image_width: 30px;

  // Ensure we're starting clean
  border-style: none;

  @if list.length($sides) > 0 {
    // For each specified border, set its style and width ready to receive the image
    @each $side in $sides {
      @if $side == "left" {
        border-left-style: solid;
        border-left-width: #{$border_width};
        $biw_left: #{$border_image_width};
      } @else if $side == "right" {
        border-right-style: solid;
        border-right-width: #{$border_width};
        $biw_right: #{$border_image_width};
      } @else if $side == "top" {
        border-top-style: solid;
        border-top-width: #{$border_width};
        $biw_top: #{$border_image_width};
      } @else if $side == "bottom" {
        border-bottom-style: solid;
        border-bottom-width: #{$border_width};
        $biw_bottom: #{$border_image_width};
      }
    }
    border-image-width: #{$biw_top} #{$biw_right} #{$biw_bottom} #{$biw_left};
  } @else {
    // No `$sides` specified so default to drawing all sides
    border-style: solid;
    border-width: #{$border_width};
    border-image-width: #{$border_image_width};
  }

  // Prevent flash of foreground colour by setting the initial colour to match the page background
  // (pretending the page is white for this example)
  border-color: white;

  border-image-repeat: round;
  border-image-source: border-image-colored(#{$color_hex});

  // This should match the size of your corner slices but you can experiement with it
  border-image-slice: 30;
}

Call it like this:

p {
  @include dotted-border(#ed397d, top, bottom);
}

Which will convert into:

p {
  border-style: none;
  border-style: solid;
  border-image-width: 30px;
  border-color: white;
  border-image-repeat: round;
  border-image-source: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='90' height='90'%3E%3Cg style='fill:%23ed397d'%3E%3Cpath d='M10 10h10v10H10zM70 10h10v10H70zM40 40h10v10H40zM40 10h10v10H40zM10 70h10v10H10zM40 70h10v10H40zM70 70h10v10H70zM70 40h10v10H70zM10 40h10v10H10z'/%3E%3C/g%3E%3C/svg%3E");
  border-image-slice: 30;
}

and this is what our custom border-image looks like

compared to CSS border-style: dashed...

... or CSS border-style: dotted