--- title: "wsdiff: Responsive diffs in plain HTML" date: 2025-07-25T23:42:00+01:00 summary: > There's many tools that render diffs on the web, but almost none that work well on small screens such as phones. I fixed this by publishing wsdiff, a diffing tool written in Python that produces diffs as beautiful, responsive, static, self-contained HTML pages. wsdiffs wrap text to fit the window, and dynamically switch between unified and split diffs based on screen size using only CSS. --- Demo ---- First off, have a demo. Because of the width of this page, the output will show an unified diff. To try out the split diff layout, make sure your browser window is wide enough and open the demo in a separate tab using `this link `__. wsdiff supports dark mode, try it out by toggling dark mode in your operating system! .. raw:: html Core Features ------------- There's many tools that render diffs on the web, but almost none that work well on small screens such as phones. I fixed this by publishing `wsdiff `__, a diffing tool written in Python that produces diffs as beautiful, responsive, static, self-contained HTML pages. wsdiffs wrap text to fit the window, and dynamically switch between unified and split diffs based on screen size using only CSS. Responsive Line Wrapping ........................ The first challenge I solved was wrapping source code lines to match the available screen space. Other tools often just show horizontal scroll bars, which is an okay workaround when you're mostly working with hard-wrapped source code on a laptop or desktop screen, but which results in catastrophic UX on any phone. I solved line breaking with a combination of CSS-controlled, web-standard word breaking rules: ``overflow-wrap: anywhere`` for source code (`MDN link `__) and ``white-space: pre-wrap`` to preserve whitespace accurately (`MDN link `__). To make both sides of the split diff align, and to align line numbers with wrapped source code lines, the diff is laid out using a `CSS grid layout`_. In side-by-side view, the layout has four columns: two for line numbers and two for the content. In unified view, the left ("old") content column is dropped, and the deleted or modified lines that are highlighted in it in side-by-side view are slotted into the remaining right column. When soft-wrapping source code, text editors will often display a little curved arrow marker to indicate that a line was soft-wrapped, and that there is not actually a newline character in the file at that location. wsdiff solves this using the same technique I used for the soft-wrapping code blocks in this blog, described `here <{{}}>`__. It inserts a string of ``"\a↳\a↳\a↳\a↳\a↳..."`` into the line number element's ``::after`` pseudo-element. This string evaluates to a sequence of unicode arrows separated by line breaks, and starting with an empty line. The ``::after`` pseudo-element is positioned using ``position: absolute``, and the parent ```` has ``position: relative`` set. This way, the arrow pseudo-element gets placed on top of the lineno span without affecting the layout at all. By setting ``overflow: clip`` on the parent ````, the arrow pseudo-element gets cut off vertically wherever the parent line number element naturally ends. Since both the line and the line number element share a grid row, the line number element always matches the height of the soft-wrapped line. Responsive Split/Unified Layout Selection ......................................... To dynamically change between unified and side-by-side views, wsdiff uses a web-standard `Media Query`_. By default, the page is laid out for side-by-side view. In the HTML source, the diff is listed as it is displayed in side-by-side view, with the old and new lines along with their line numbers interleaved. The magic happens when the media query gets triggered by a narrow screen width. The media query re-adjusts the layout in four core steps: 1. All unchanged lines in the left (old) column are hidden. 2. The left content column of the grid layout is hidden, so that now there are three columns: old line number, new line number, and unified content. 3. All deleted or changed lines from the left (old) column are re-located to the right column. They naturally slot in in the right spot because they already appear in the right order in the HTML source. 4. By slotting in the old lines in the right column, we have created gaps in the line number columns. Every deleted line has an empty cell in the new line number column, and every inserted line has one in the old line number column. The CSS adjusts the layout of these empty cells such that the border lines align nicely, and it overrides the newline markers so that they only show in the right (new) line number column, not both. Since this is all CSS, it happens automatically and near-instantly. Since it is using only web standard features, it works across browsers and platforms. Unchanged Line Folding in CSS ............................. When showing the diff of a large file, it is usually best to hide large runs of unchanged lines. wsdiff does this similar to code folding in text editors. When a long run of unchanged lines is detected, a marker is placed spanning the diff. This marker contains a checkbox that can be toggled to hide the unchanged lines. This feature is done completely in CSS using a ``:has(input[type="checkbox"]:checked)`` selector. The actual mechanics are quite simple. To cleanly hide the lines, they must be placed in a container ``
``. That div has a CSS subgrid layout using ``display: grid; grid-template-columns: subgrid;``, meaning that its contents align to the surrounding diff grid. Dark Mode ......... Integrating a website with the OS-level dark mode is surprisingly easy. All you need is a `Media Query`_ that selects for ``@media (prefers-color-scheme: dark)`` and you're good. wsdiff uses named colors using `CSS Custom Properties`_, so the actual dark mode media query only needs to override these color properties, and the rest of the CSS will be re-computed automatically. Limitations: Text selection ........................... A limitation in having a combined, single HTML source for both side-by-side and unified diffs is that text selection only works naturally in either mode. You can't make text selection work in both simultaneously without re-sorting the lines in the HTML source, since there is no way to override the text selection order from pure CSS. In wsdiff, I worked around this issue by just disabling text selection on the unchanged lines in the left (old) column, so selecting text in the right column copies the unified diff as one would expect. Try it yourself! ---------------- You can find the demo from above at `this link `__. You can install wsdiff yourself `from PyPI `__: .. code:: sh $ pip install -U wsdiff Successfully installed wsdiff-0.3.1 $ wsdiff old.py new.py -o diff.html .. _`CSS grid layout`: https://css-tricks.com/snippets/css/complete-guide-grid/ .. _`Media Query`: https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_media_queries/Using_media_queries .. _`CSS Custom Properties`: https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_cascading_variables/Using_CSS_custom_properties