SJ (Scott Jehl)

How would you build Wordle with just HTML & CSS?

photo of scott jehl profile headshot

By Scott Jehl

I’ve been thinking about the questions folks are typically asked in front-end interviews these days, and how well those questions assess a candidate's depth of understanding of web standard technologies, and not just their ability to employ JavaScript algorithms and third-party frameworks. It made me think about the sort of questions I would like to hear or ask in an interview myself.

I think this sort of question would be a useful one:

"How would you attempt to build Wordle (...or some other complex app) if you could only use HTML and CSS? Which features of that app would make more sense to build with JavaScript than with other technologies? And, can you imagine a change or addition to the HTML or CSS standard that could make any of those features more straight-forward to build?"

Now, to be clear, this is meant to be a contrived exercise. I don't actually think a game app like Wordle should be built without using any JavaScript, (though I do think the app could be better if it functioned at all without it!). In reality, I feel there are many user experience enhancements to an app like Wordle that only JavaScript is capable of providing.

Still, I like this question as an exercise. Discussing any approaches to this challenge will reveal the candidate's broad knowledge of web standards–including new and emerging HTML and CSS features–and as a huge benefit, it would help select for the type of folks who are best suited to lead us out of the JavaScript over-reliance problems that are holding back the web today.

Interview questions aside though, I also thought this would be a fun little experiment to explore myself! So I took a few hours yesterday to do just that, and in the rest of this post I'll cover how I approached it.

A Quick Attempt

Here's a CodePen link with the demo and code for my first attempt:

Wordle, just HTML & CSS

By the way, if you aren't here to play games (or you want to save some time), you can find the answer to the puzzle in this little disclosure:

Reveal AnswerPROUD

The approach I took for this example leans hard on HTML form validation for the game logic. I'm using a text input for each letter of the word, with pattern attributes describing the correct letter. The browser can natively use a pattern attribute to determine whether the input's value is "valid," which in the case of this contrived example can mean that a letter is correct.

To give an example, if the correct word was "TIGER" then the first letter's input could have a pattern attribute like this: pattern="[tT]" which would match lowercase and uppercase T. Additionally, the inputs have a maxlength attribute to limit their content length to 1 character.

HTML:

<input type="text" id="input1" name="input1" maxlength="1" pattern="[tT]">

Through these two attributes alone, we can already write a CSS selector to visually display whether a typed letter is correct or not, using a gray or green background similar to Wordle itself, like this:

input[type="text"]:invalid {
    background-color: lightgray;
}
input[type="text"]:valid {
    background-color: lightgreen;
}

Neat! But there are some problems.

Delaying Validation

As written above, the validation styles will show immediately as you are writing the letters, rather than after you type the entire word, which makes it too easy to change your answer as you see whether each letter is correct or not. That's not how Wordle works!

Instead, we want the validation to kick in only after a word is entered and complete. You could try to do this in a number of ways. I decided to use the :not() and :has() CSS selectors to qualify the validation styles to only apply when the user is done entering a whole word. The game board grid uses an HTML table element and each "word" row sits in a tr element. The following styles will apply to tr elements that do not contain focused or empty inputs, meaning every input has a character in it and the user is no longer typing in any of the inputs.

To gauge whether the inputs are empty, I added a placeholder attribute to the HTML input, which conveniently pairs with the :placeholder-shown selector that matches empty inputs. Here's the HTML and CSS:

HTML:

<input type="text" id="input1" name="input1" maxlength="1" pattern="[tT]" placeholder=" ">

CSS:

/* invalidate only non-focused populated row */
tr:not(:has(input:placeholder-shown, input:focus)) input[type="text"]:invalid {
    background-color: lightgray;
}
/* validate only non-focused populated row */
tr:not(:has(input:placeholder-shown, input:focus)) input[type="text"]:valid {
    background-color: lightgreen;
}

Nice. Now the form validation happens after a complete guess for each row. Small note: iOS Safari seems to only allow this selector to work with placeholder attributes that have at least some sort of string, so I added a blank space to its value, but there's probably a better character that I could use (maybe "A"?).

Disabling Future and Completed Rows

Another thing I wanted to do to match the game's behavior is to only allow interaction with the inputs in the active row. Admittedly, I didn't fully pull this one off for restricting keyboard focus, but I was at least able to disable touch and click-focus on those inputs by using a similar selector to the prior ones, and disabling pointer events.

/* disallow next row until row is populated */
tr:has(input:placeholder-shown) ~ tr input,
/* disallow revisiting populated row */
tr:not(:has(input:placeholder-shown, input:focus)) input {
    pointer-events: none;
}

Good enough, moving on.

Announcing a Win

When the correct word is typed, it would be nice to hide the rows after the correct guess and display a message that the game is over. To do this, I used a rule similar to the ones above, with some additional generated content to announce the win.

tr:not(:has(input:placeholder-shown, input:invalid, input:focus)) ~ tr {
    display: none;
}
table tr:not(:has(input:placeholder-shown, input:invalid, input:focus)):after {
  content: "You Win!";
  /* ...some additional less critical styles here */
}

Improvement to test: This winning announcement should be accessible, and I'm not sure that CSS generated content will be announced in assistive tech, even if I use an aria-live region. Perhaps it'd be better to toggle the CSS display of a status message that follows the table, or have a status column on the right with messages that can be revealed as needed. I'm thinking on this.

So What About Tan?

Now, admittedly, so far the game only offers the gray and green states of Wordle, which represent letters that are either not in the word at all (gray), or are entirely correct and in the right place (green). Unlike the real Wordle, it's missing the "tan" state that tells you a letter is in the word, yet in the wrong place. This means that the game is playable, but it's pretty hard!

Unfortunately, HTML validation only offers a binary answer, and what we really want here is a sort of in-between state that's valid but not super-valid.

Validating for Tan

So pattern validation only allows us to match for one type of pattern, but that pattern could refer to the whole word if we wanted to use it for the tan state instead of the green state. For example, the following pattern would match every letter in TIGER, both lowercase and uppercase: pattern="[tTiIgGeErR]".

Once again, our familiar CSS could target that valid pattern to color an input tan if it contains a letter that is in the word at all:

/* validate only non-focused populated row */
tr:not(:has(input:placeholder-shown, input:focus)) input[type="text"]:valid {
  background-color: tan;
}

So now our problem is tan works, but green doesn't. We need a little help showing the green state for letters that are not ONLY in the word, but in the right place as well.

Extending with JavaScript

Okay. Let's add some JavaScript to get 3 states instead of 2.

The following 10 lines add an input event handler (which fires whenever the user changes the input's value) to all of the inputs in the page. It will grab a regular expression pattern in that input's custom data-correct-pattern attribute and add a data-incorrect attribute to the input when the pattern doesn't match the input's current value:

document.querySelectorAll("input").forEach(inpt => {
  inpt.addEventListener("input", function(e) {
    const correct = this.value.match(new RegExp(this.getAttribute("data-correct-pattern")));
    if (correct) {
      this.removeAttribute("data-incorrect");
    } else {
      this.setAttribute("data-incorrect", true);
    }
  });
});

With that attribute toggle, I can write some CSS to add the green state! Here are my 3 states, the third of which using that new data-incorrect attribute:

/* invalidate only non-focused populated row */
tr:not(:has(input:placeholder-shown, input:focus)) input[type="text"]:invalid {
  background-color: lightgray;
}
/* validate only non-focused populated row */
tr:not(:has(input:placeholder-shown, input:focus)) input[type="text"]:valid {
  background-color: tan;
}
/* validate only non-focused populated row */
tr:not(:has(input:placeholder-shown, input:focus)) input[type="text"]:not([data-incorrect]) {
  background-color: lightgreen;
}

You can view the demo of this version here:

Wordle, almost just HTML & CSS

Caveats and Implementation Ideas

Needless to say, these examples don't do everything that the real Wordle does, so I won't attempt to itemize the differences here. Perhaps the most important difference to note is that there's no validation of real words, so you can enter any old letters you please.

I also want to explore how to make the state of the app persistently make sense to assistive technology, which might mean having an additional table column with cells containing the result of each validation (toggling the display of various possible messages so that they are accessible. Maybe that'll be a followup post (and if you're interested, here's a more practical article about improving actual-Wordle's accessibility).

Also, um, the puzzle is always the same answer? True! The real Wordle has a different word every day. A fuller implementation of an approach like this would need some dynamic handling on the server side to deliver different HTML patterns for the word of the day.

Having said all of that, I find it pretty awesome that HTML and CSS alone can get us so far today, and it's totally possible that there are different HTML patterns that could pull off all 3 colors without JS already, as well (perhaps if we built a large QUERTY keyboard out of radio inputs...)

What if :valid was a function?

Okay, as for the the last part of my original question. What could be added to HTML or CSS to make that last example possible without JavaScript? One thing comes to mind for me: an ability to validate multiple patterns on one input.

For example, let's revisit that pattern attribute value. The existing pattern="[tTiIgGeErR]" will match for all of the letters in the word Tiger, uppercase or lowercase. From that, you get a simple yes or no match for whether the letter is in the word at all, again like this:

input[type="text"]:valid {
  background-color: tan;
}

That gives us the tan color, but again we still have the green pattern to worry about. What if that :valid pseudo-class allowed for arguments, though? If it did, then I could pass a string to it, like :valid("[gG]") to specifically match for a g or G. Even better, maybe the value could accept the standard CSS attr() function too, which would allow the HTML to define additional patterns for the CSS to validate. In my second demo, I used a data-correct-pattern attribute to define that second pattern.

Here's how the markup and CSS might look for that dreamed-up feature, which could allow the second demo to work without JS:

HTML:

<input type="text" id="input1" name="input1" maxlength="1" pattern="[tTiIgGeErR]" data-correct-pattern="[tT]" placeholder=" ">

CSS:

/* invalidate only non-focused populated row */
tr:not(:has(input:placeholder-shown, input:focus)) input[type="text"]:invalid {
  background-color: lightgray;
}
/* validate only non-focused populated row */
tr:not(:has(input:placeholder-shown, input:focus)) input[type="text"]:valid {
  background-color: tan;
}
/* validate only non-focused populated row */
tr:not(:has(input:placeholder-shown, input:focus)) input[type="text"]:valid(attr(data-correct-pattern)) {
  background-color: lightgreen;
}

JavaScript:

...none needed.

Maybe I'll file an issue in the CSS standard asking for :valid(attr(...))! :)

Thanks!

I hope you enjoyed this post. Please feel free to reach out with ideas or questions or improvements on Mastodon. Thanks for reading!

By the way... I am currently exploring work opportunities for 2024! If you think I might make a good addition to your team, whether full-time or as an independent contractor, please reach out.