ASCII Art in CSS

Introduction

“Perfection is achieved not when there is nothing more to add, but when there is nothing left to take away.”
Antoine de Saint-Exupéry

This is a guide to using ASCII art in your CSS. This is not a beginner's guide and more suited to those settling down in CSS. Thus, the prerequisites go as follows: You must have built a website, created or altered a stylesheet, feel comfortable in CSS itself and have some degree of ASCII-experience.

The basics

ASCII art is a lo-fi art medium that consists of alphanumeric characters, and has its origins in the 1960s, though it technically originated a century prior via the typewriter. It is a staple of Usetnet, BBS and IRC communications, but resumes today as a darling of the small web. It is lightweight, loads instantly, cannot be hotlinked, and it costs nothing to display. If you have an interest in 1-bit art, deliberately primitive mediums or you otherwise want to celebrate being anything but conventional, this approach might be for you.

Note that there are already several ways to incorporate ASCII into a website. If you want to place ASCII art directly in your HTML rather than your CSS, see Frills guide at frills.dev/blog/240212-accessible-ascii/. Not only does it tell you how to plug ASCII into your HTML, but it shows you how to do it in a way that respects screen-readers and doesn't make the visually-impaired think you're trying to summon a demon.

Below is an example of this, using Frills example fish, itself created by Joan "jgs" Stark.

o           .'`/
    '      /  (
  O    .-'` ` `'-._      .')
      _/ (o)        '.  .' /
      )       )))     ><  <
      `\  |_\      _.'  '. \
        '-._  _ .-'       '.)
    jgs     `\__\

Part 1: Setting the Scene

Step 1: Learning about pseudo-elements

::before and ::after are CSS pseudo-elements. As their names suggest, your browser will place these before and after the content you apphend them to. To render things, a pseudo-element requires a content property, even if that value is just an empty string. If you have not used pseudo-elements before, the syntax looks like this:

body::before {
  content: "hello";
}

That is enough to render the word “hello” before all of the body’s content. Keep in mind this is a hard-coded message, and cannot be interacted with past simply observing it.

By default, pseudo-elements are flow content. They sit exactly where they would naturally fall in the document immediately adjacent to their parent element’s content, inline with everything else. On an h1, ::before renders directly before the heading text and ::after renders directly after it, on the same line. If the element is a block, they will appear to sit above and below it, but they are not positioned they are simply flowing naturally in the document the way any content would.

What we're going to do here is override these defaults for our own artistic intentions. position: fixed is what pulls the pseudo-elements out of the normal document flow and pins them to the viewport. Without it, body::before would simply render at the very top of the page before all content, and body::after would render at the very bottom after everything both in the flow, neither floating at the sides.

If you want to apply the same technique to a different element say, framing a specific section or article rather than the whole page the approach is identical, but the behaviour changes in important ways. The containing block shifts to that element rather than the viewport, percentage-based sizing works relative to the parent, and getting consistent results requires more deliberate positioning work than applying it to body does. This guide focuses on body because it is the most predictable and the most useful for full-page framing.

Step 2: Understanding where body sits

Body is the root container of everything visible on the page. In most modern layouts it sits outside and around your actual content elements your main, your header, your layout wrappers. If your layout is a centred flexbox column, body is the space that surrounds it on all sides. Because of this, attaching pseudo-elements to body means they exist in that outer layer, framing the content inside.

Step 3: Considering accessibility

The content property is generally not exposed to the accessibility tree, which means screen readers will typically ignore pseudo-element content. This is not an absolute guarantee some older screen readers and certain configurations will announce generated content but it is the typical behaviour in current browsers. The tradeoff is that pseudo-element content is strictly decorative: you cannot select it, you cannot interact with it, and it carries no semantic meaning. You can alter it with Javascript, though I do not cover this here. For framing, atmosphere, and ornamental work along the edges of a page, that is exactly what you want.

Step 4: Setting up a placeholder

Before you source or create your ASCII, you'll want to create a placeholder. This is common practice as is, but bears repeating given the uniquely slow workflow of ASCII, both in creating and editing. If you spend time creating or adapting a large, detailed piece and then discover it does not fit your layout, sits at the wrong size, or clips in unexpected ways, you'll be stuck with a piece that while pleasant, is functionally useless for the task.

One method of creating a placeholder is to use the free in-browser ASCII drawing tool at asciiflow.com. Acquaint yourself with its UI, then draw a box to your liking and drop it into your CSS using the method described in the following sections. From there, you can toy with it to your leisure until you feel ready to create an actual piece. One caveat to this though is that ASCII Flow displays characters using the browser’s default font, which is typically proportional. What you see in the editor will not precisely match what you get in CSS with a monospace font spacing and alignment can shift noticeably. Use ASCII Flow for rough blocking-in only, and treat its preview as approximate rather than definitive. Once your font is set correctly in CSS, verify the placeholder there before moving on.

Throughout this process, your browser’s developer tools are your best friend. In any browser, right-clicking on your page and selecting Inspect (or pressing F12) opens the DevTools panel. From here you can find your body::before and body::after pseudo-elements listed under the body element in the HTML tree, and you can see and edit all of their CSS properties in real time. Changes you make in DevTools are temporary and reset on page refresh, but they let you experiment with values font sizes, opacity, positioning without editing your stylesheet every time. Get comfortable using this panel early. It will save you a great deal of guesswork.

Step 5: Choosing your font

Font choice is not just aesthetic here. It is structural, and it is worth spending time on before you commit to a piece of art.

ASCII art relies on characters aligning precisely on a grid. Every character is assumed to occupy exactly the same width this is what is meant by a monospace font. In a proportional font, narrow characters like i take up less horizontal space than wide ones like m, which means columns will not align and your art will look wrong. This is why monospace is non-negotiable for ASCII art. It is the same reason code editors default to monospace fonts.

Beyond the monospace requirement, individual glyph design matters a great deal. Diagonal lines, borders, and connected shapes are drawn using characters like \, /, |, and +. If your chosen font renders any of these poorly if backslashes sit at the wrong angle, if the spacing feels uneven, if the characters do not connect visually the way you expect your art will look wrong regardless of how carefully you drew it.

Do not assume your existing site fonts will work. The font used for body text on this site, Special Gothic, was tried first and rejected. The header font, Alagard, was also tried and rejected. Neither communicated the right aesthetic, and neither handled diagonals in a satisfying way. The font that works for running text is often not the font that works for ASCII art, and it is worth approaching this as a separate decision entirely.

The font used here is a Tandy font from the Ultimate Oldschool PC Font Pack, available at int10h.org/oldschool-pc-fonts/. This pack is an extensive archive of fonts ripped from vintage hardware and software. The Tandy font was chosen specifically because it handles diagonal characters well while retaining the old hardware look. When evaluating a font, try a test string that includes your most commonly used characters before committing if the font makes them look weak, misaligned, or wrong, move on.

To load a custom font, declare it with @font-face. This is how you tell the browser about a font file you are hosting yourself, rather than using a system font or a web font service:

@font-face {
  font-family: 'Tandy';
  src: url('/assets/fonts/your-tandy-font.ttf')
       format('truetype');
  font-weight: normal;
  font-style: normal;
}

Once declared, assign it to a CSS variable in :root for easy reuse:

:root {
  --asciifont: 'Tandy', monospace;
}

The monospace fallback at the end of the font stack ensures that if the custom font fails to load, the browser will substitute a system monospace font rather than a proportional one which at least preserves character grid alignment. Be aware that the generic monospace keyword resolves differently across operating systems: Courier New on Windows, Courier on macOS. These differ in size and weight. Setting a specific named fallback such as 'Courier New', monospace gives you more predictable behaviour if that matters to you.

You can then reference the variable in your pseudo-element with:

font-family: var(--asciifont);

Because the font is already declared and loaded for use elsewhere on the page, using it in a pseudo-element costs nothing extra.

Part 2: Creating Your Art

Once you have your art, you need to adapt it for use inside a CSS content string. This is where most people run into problems, which we'll address now. So let's bring back our fish. In a text file, a small piece might look like this:

o           .'`/
    '      /  (
  O    .-'` ` `'-._      .')
      _/ (o)        '.  .' /
      )       )))     ><  <
      `\  |_\      _.'  '. \
        '-._  _ .-'       '.)
    jgs     `\__\

In CSS, the same piece needs to be written as a single unbroken string, with line breaks represented explicitly:

content: "o           .'`/\A"
         "    '      /  (\A"
         "  O    .-'` ` `'-._      .')\A"
         "      _/ (o)        '.  .' /\A"
         "      )       )))     ><  <\A"
         "      `\\  |_\\      _.'  '. \\\A"
         "        '-._  _ .-'       '.)\A"
         "    jgs     `\\__\\";

There are two conversions you need to make.

Backslashes. In CSS strings, a backslash is an escape character, meaning the browser treats it as an instruction rather than a literal character. Every backslash in your ASCII art must be doubled: \ becomes \\. If you skip this, CSS will try to interpret the character following the backslash as an escape sequence and your art will break. This is the single most common source of errors when adapting ASCII art for CSS. Your source code will show \\, but the browser will render a single \.

Line breaks. You cannot place a literal line break inside a CSS content string. The CSS unicode escape \A represents the newline character (U+000A). Place it at the end of each line in your art, immediately before the next line begins, all as one continuous string.

The conversion workflow:

  1. Copy your ASCII art into a plain text editor.
  2. Find and replace: every \ becomes \\.
  3. Find and replace: every line break becomes \A.
  4. Remove the final \A at the very end, as there is no line after it.
  5. Wrap the result: content: "..."; and place it in your selector.

For large and complex pieces like full-height side panels, this produces a significant block of text in your stylesheet. That is normal, and it is still a fraction of the size of an equivalent image file. For very large pieces, some people write a small python script to automate the find-and-replace work rather than doing it by hand. A revised version of this guide will have a free python file ready to plug-and-play.

Step 1: Setting the properties

Getting the art into the content string is only part of the job. Without the right supporting properties the result will render as an unreadable mess. These are the properties that make it work.

white-space: pre
The single most important property. By default, browsers collapse all consecutive whitespace spaces, tabs, line breaks down to a single space. white-space: pre instructs the browser to preserve every space and every newline exactly as written. Without it, your \A newlines will be ignored, your spacing will collapse, and your art will render as one long unbroken line. Always set this.
font-family
Must be set explicitly on the pseudo-element rather than relying on inheritance, which is not guaranteed for generated content.
font-size
Controls how large each character renders. font-size: 14px is the value used here small enough that a tall piece fits within the viewport height without clipping, large enough that the characters read clearly. Establish this once your placeholder is in place and adjust until it feels right.
line-height: 1
Sets the vertical distance between rows of text to exactly one times the font size, with no additional space above or below each row. Any value above 1 introduces vertical gaps between rows, which breaks the alignment of anything that spans multiple lines borders, diagonal lines, connected shapes. Always set line-height: 1 for ASCII art.
color
Must be set explicitly, as pseudo-elements do not automatically inherit colour from their parent in all contexts. Using a CSS variable defined in :root and overridden in a prefers-color-scheme media query means the art switches colour alongside everything else on the page automatically, without any additional work:
:root {
  --color: #D3D3D3;
}

@media (prefers-color-scheme: light) {
  :root {
    --color: #080808;
  }
}
This is worth thinking about early. ASCII art that looks striking on a dark background can become visually heavy or cluttered on a light one, and vice versa. If your site supports both modes, test your art in both before settling on a final piece.
opacity
A lower value softens the art in both modes, reducing the contrast difference between them. opacity: 0.6 is the value used on this site. Adjust to taste.

Part 3: Display and Behaviour

With the art rendering correctly, these properties control how it sits on the page and behaves in context.

position: fixed
Removes the element from the normal document flow entirely and pins it to a fixed position in the viewport. It will stay exactly where you put it regardless of how far the user scrolls. bottom: 0 anchors the panels to the bottom of the viewport. left: 0 on body::before and right: 0 on body::after places them in the two lower corners respectively.

One important caveat: if any ancestor element has a CSS transform applied to it, position: fixed stops working as expected. A transform creates a new containing block, which means the pseudo-element will pin relative to that transformed ancestor rather than the viewport. This is a common and confusing bug. If your panels are not sitting where you expect, check whether any ancestor has transform set and remove it or restructure accordingly. See the troubleshooting section for more detail.
z-index
Controls the stacking order of elements that overlap, but only has an effect on positioned elements. z-index: -1 on the pseudo-elements places them behind everything in the stacking context by default, which is usually what you want. If your content elements are explicitly positioned and you need fine-grained control, set z-index values on both the pseudo-elements and the content elements explicitly.
max-width: 25vw
Limits each panel to a quarter of the viewport width. The vw unit stands for viewport width 1vw is one percent of the browser window’s total width. Using vw units means the panel scales proportionally as the window resizes. With 25vw on each side, the central half of the screen remains clear for your content. Adjust max-width to match the actual width of your art rather than treating 25vw as a fixed recommendation.
overflow: hidden
Clips any content that extends beyond the element’s boundary without introducing scrollbars.
pointer-events: none
Makes the pseudo-element completely transparent to mouse clicks and touch input. Without this, the pseudo-element intercepts interaction intended for content behind it. Always set this on decorative pseudo-elements.
padding-bottom on body
If your panels are tall, the bottom of your page content may sit behind them. Adding padding-bottom to body pushes your content up enough to clear the panels. To calculate the right value: multiply your font-size by the number of lines in your art. For example, at 14px with 40 lines, you need roughly 560px. Measure in DevTools to confirm, as font rendering can introduce small variations.
Responsive behaviour
On narrower viewports, fixed side panels will encroach on your content no matter how carefully you have sized them. The correct solution is to hide them entirely below a breakpoint with display: none. Choose your breakpoint by resizing your browser window until things start to feel cramped, and set it there.

Step 2: The complete implementation

A complete implementation for a pair of decorative side panels looks like this. All shared properties are declared once on the grouped selector; only the differing values content and the horizontal position are declared separately.

@font-face {
  font-family: 'Tandy';
  src: url('/assets/fonts/tandy.ttf')
       format('truetype');
  font-weight: normal;
  font-style: normal;
}

:root {
  --asciifont: 'Tandy', monospace;
  --color: #D3D3D3;
}

@media (prefers-color-scheme: light) {
  :root {
    --color: #080808;
  }
}

body {
  /* font-size * line count of your art.
     14px * ~40 lines = 560px. Adjust. */
  padding-bottom: 560px;
}
body::before,
body::after {
  position: fixed;
  bottom: 0;
  overflow: hidden;
  max-width: 25vw;
  font-family: var(--asciifont);
  font-size: 14px;
  line-height: 1;
  white-space: pre;
  color: var(--color);
  opacity: 0.6;
  pointer-events: none;
  z-index: -1;
}

body::before {
  content: "+-------+\A| test  |\A+-------+";
  left: 0;
}

body::after {
  content: "+-------+\A| test  |\A+-------+";
  right: 0;
}

@media screen and (max-width: 1800px) {
  body::before,
  body::after { display: none; }
  body { padding-bottom: 0; }
}

Replace the content strings with your adapted art, substitute your own font and colour variables, and adjust the padding-bottom, breakpoint, and opacity to taste.

Part 4: Troubleshooting

The ASCII renders as one long line.
You are missing white-space: pre. Add it to your pseudo-element and the line breaks should kick in.
The art has gaps between the rows.
Your line-height is above 1 or your font is very spaced. Set line-height: 1 explicitly.
The art looks garbled around diagonal lines.
Your backslashes have not been doubled. Go back through your content string and make sure every \ is written as \\. This is easy to miss on a large piece.
The font is not loading.
Check your @font-face declaration and confirm the file path is correct. Open DevTools, find the pseudo-element, and look at the computed font-family value to see what the browser is actually using.
The panel is overlapping my content.
Either your max-width is too wide for your layout, or your z-index is too high. Try z-index: -1 first. If the art still overlaps interactive elements, confirm that your content elements are explicitly positioned and have a z-index higher than the pseudo-element. Also check that padding-bottom on body is large enough to clear the panels at the bottom of the page.
The panel is not hiding on mobile or smaller screens.
Check your media query breakpoint. Open DevTools, resize the browser window, and watch at what width the panels start to crowd your content. Set your breakpoint to match.
It's fine in dark mode but wrong in light mode, or vice versa.
Test your opacity value in both modes. You may need a slightly different value for each, which you can set using a prefers-color-scheme media query targeting the pseudo-element directly.
The panels don't stick to the viewport.
A CSS transform on an ancestor element is the most likely cause. Any transform creates a new containing block and breaks position: fixed. Find the element with the transform applied and remove it, or restructure your layout so no ancestor of body has a transform at the relevant breakpoint.
Nothing renders at all.
Make sure your pseudo-element has a content property. Without it even an empty one the pseudo-element will not render. Also confirm your selector is correct: body::before, not body:before. The single-colon syntax is older and may behave differently in some contexts.

Further reading

Official Documentation

MDN Web Docs CSS / developer.mozilla.org/en-US/docs/Web/CSS
The practical reference for CSS. The first place to look when you need to understand what a property does, what values it accepts, and which browsers support it.

MDN Web Docs HTML / developer.mozilla.org/en-US/docs/Web/HTML
The same, for HTML.

W3C World Wide Web Consortium / w3.org
The standards body that defines what HTML and CSS actually are.

WHATWG HTML Living Standard / html.spec.whatwg.org
The living HTML standard. If there is ever a discrepancy between what MDN says and what a browser actually does, this is the final word.

Guides and Tutorials

Stone Story ASCII Tutorial / stonestoryrpg.com/ascii_tutorial.html
Tutorials from the creator of Stone Story RPG, a game made entirely in ASCII. Covers the craft in substantial depth.

ASCII Characters Are Not Pixels Alex Harri / alexharri.com/blog/ascii-rendering
A deep dive into ASCII rendering using shape vectors. More technical than this guide but worth reading if you want to understand how ASCII rendering works under the hood.

Frills Making ASCII Art Accessible / frills.dev/blog/240212-accessible-ascii/
The definitive short guide to the HTML approach. Essential reading if you ever want to place ASCII art directly in your markup rather than your stylesheet.

Archives and References

Joan Stark ASCII Art Archive / oldcompcz.github.io/jgs/joan_stark/
One of the largest and most referenced collections of ASCII art on the web.

ASCII Art Website Christopher Johnson / asciiart.website
Johnson’s personal collection, running since 1994.

ASCII Art by Sebastian Stöcker / ascii-art.net
Another long-running personal collection, online since 2000.

Roy’s ASCII Corner / roysac.com
Carsten Cumbrowski’s ASCII portfolio, online since 2000.

16colors / 16colo.rs
An archive of early ASCII and ANSI art.

FUNGI / fungi.neocities.org
A gallery of user-submitted ASCII fungi and other whimsical things.

Tools and Fonts

ASCII Flow / asciiflow.com
Free in-browser ASCII drawing tool. Good for creating placeholders and geometric compositions.

Ultimate Oldschool PC Font Pack / int10h.org/oldschool-pc-fonts/
An extensive archive of fonts from vintage hardware and software. The source of the Tandy font used on this site.

TombZone / tomb.zone
Make your own ASCII tombstones in the browser.