The Spanner logo
    • Home
    • Blog
      • Blog home
      • RSS
    • Login
    • Home
    • Blog
      • Blog home
      • RSS
    • Login
    The Spanner logo

    The Spanner
    Web security blog

    Made by Gareth Heyes
    Follow me on Twitter: @garethheyes

    Javascript for hackers!

    Hackvertor logo
    Shazzer logo
    My Github account
    Recent posts
    Introducing Feedworm: A Privacy-First RSS Reader That Lives in DevToolsSpeedy RSVP extensionAutoVaderHackvertor history and tag finderShadow Repeater v1.2.3 releaseBurp Hackvertor v2.1.24 releaseHacking roomsXSSing TypeErrors in SafarivalueOf: Another way to get thisMaking the Unexploitable Exploitable with X-Mixed-Replace on FirefoxThe curious case of the evt parameterCSS-Only Tic Tac Toe ChallengeRewriting relative urls with the base tag in SafariBypassing DOMPurify with mXSSNew IE mutation vectorHow I smashed MentalJSMentalJS DOM bypassAnother XSS auditor bypassXSS Auditor bypassBypassing the IE XSS filterUnbreakable filterMentalJS bypassesmXSSJava SerializationBypassing the XSS filter using function reassignmentRPOSandboxed jQueryX-Domain scroll detection on IE using focusEpic fail IEnew operatorDecoding complex non-alphanumeric JavaScriptHacking FirefoxDOM ClobberingBypassing XSS AuditorThe evolution of codeNon-Alpha PHP in 6-7 charsetTweetable PHP-Non AlphaMentalJS for PHPOpera x domain with video tutorialSandboxing and parsing jQuery in 100ms

    CSS-Only Tic Tac Toe Challenge

    By Gareth Heyes (@hackvertor)

    Published 1 year 1 month ago • Last updated April 10, 2025 • ⏱️ 4 min read

    ← Back to articles

    Isometric graphic showing a person at laptop with a tic tac toe board near the wall

    In this post, I dive into one of my more absurd yet wildly entertaining experiments: building a partly functional Tic Tac Toe game using pure CSS - no JavaScript, no HTML, just stylesheets.

    You can play with the game here: Almost Tic Tac Toe in pure CSS!

    I love throwing strange challenges at myself, especially when I have no idea whether they're even possible. That's half the fun. This time, I was deep into scroll-driven animations and thought, what if scroll position could somehow represent game state? The idea was ridiculous, which made it perfect.

    The first problem I had was I had no elements to play with, I couldn't use any HTML tags. Or could I? The browser defines certain tags even if they don't exist. This means there's a HTML, body and head tag. This is pure CSS though so I can't define attributes on the tags and all I can do is use pseudo elements and control text with the content property like this:

    html:before { content: "Hello world!"; }

    Cool I could create text and move it about using these elements and each element has a before/after pseudo element. So I could style those elements to create headings etc. I thought about the board itself, I could produce that just using text and use CSS variables for the state. I used letter spacing to separate the board pieces and new lines to create the rows:

    white-space: pre; content: var(--choice1, "-") var(--choice2, "-") var(--choice3, "-") "\A" var( --choice4,"-") var( --choice5,"-") var(--choice6,"-") ...

    Cool I could now build a board 3x3 but then the next problem was I had to change the choice to "X" or "O". This was really tricky because I can't use anchors or checkboxes (this is pure CSS). I thought about this for a while and remembered about scroll driven animations, maybe I could use the scroll state for the board states?

    On the body element I assigned two animations:

    body { animation-name: changeStateX, changeStateO; animation-timeline: scroll(y nearest), scroll(x nearest); }

    This syncs the animation to the scrollbars both vertically and horizontally. But you need the body element to scroll right, how could I do that? Well, you can make the head element very large and change it's display to block:

    head { display: block; height: 5000px; width: 5000px; }

    This makes the body scroll, all we need to do now is to sync the scroll with the animations, the animations simply set a CSS variable like this:

    @keyframes changeStateO { 1% { --o1: "O"; } 2% { --o2: "O"; } .... }

    This changes the variable name based on the scroll position. There is an unsolved problem though, we can't choose the board position to play from :( and the choices overwrite each other. To partly solve the second problem, you can use variable fallbacks, so if one variable isn't defined it will fallback to the other, this almost works but I couldn't figure out a way to make both choices stick, once the variable was defined that became the choice and if the fallback was the other choice then it wouldn't get overwritten. The end result is you can play "X" and "O" can't overwrite it but not the other way round :(

    --choice1: var(--x1, var(--o1));

    So the above sets the choice variable to x if it's defined otherwise it reverts to o. I've spent way too much time on this and the CSS is still pretty simple but I thought I'd write about it because it was quite fun and maybe someone more intelligent than me can solve the two remaining problems. Anyway here is the partially working game:

    Almost Tic Tac Toe in pure CSS!

    Update ...

    @rebane2001 made their own version which is much cooler and you can actually click different parts of the board. They use container queries and transitions change the state of the board. Check out their poc!

    ← Back to articles