• Tags: hackernews, css, js
  • Vladimirs Nordholm

My Hacker News is a little bit prettier

Hacker News has a dated style. I’ve come to like the design, but it could really do with some modernization. So I did.

Hacker News website in light mode Hacker News website in dark mode
This is what I see when I go to news.ycombinator.com

This is not a clone of the site. This is what I see when I go to Hacker News – all done with some custom injected CSS (and JavaScript). I will put all the code at the bottom of this post.

There are generally three changes made:

  1. Spacing in the header, between links, and in comments
  2. System dependent “dark mode”
  3. Highlighting trending topics

There are some other smaller changes which I won’t go into detail about, but generally just small tweaks to make the site work better in smaller viewports.

Hacker News’ HTML is Horrible (with a captial H)

A stroll down Hacker News’ HTML strucutre.

Hacker News is a “table-oriented” website. Everything is either a table, or a nested table. This leads to this abomination of a CSS selector for just selecting only the main content:

#hnmain > tbody > tr:nth-of-type(3) > td > table { … }

The worst offender however, in my opinion, is how every link is split up into three rows, with the last row just for spacing.

     ┌ row - titles
post │ row - extras
     └ row - spacer
     ┌ row - titles
post │ row - extras
     └ row - spacer

I don’t understand why, but I’m guessing this was the way to go when Hacker News launched, and the page never changed ever since :shrug:.

In general I only changed the spacing on the rows, and not much else. For consistency, I hid the “spacer row”, and added some padding on the top of the first row, and some bottom padding on the second.

main links main links
A selection of regular links

Personally I would kept up with the nested tables and have every link be its own table, but they chose another route.., except they didn’t! If you go to your threads, /threads?id=username, every comment is its nested table. Aarg!

If anything, I’ve got to give credit to the site for being very consistent per page section list of something.

Another pain point was that the footer does not always appear on every page. I first used tr:last-of-type to hide the last row, but on the “submit page” and “user page”, there is no footer – the last row is the actual content!

I had to resort to a unconventional CSS selector to find the footer:

#hnmain > tbody > tr:last-of-type > td:has(img[src="s.gif"])

Yes, I am checking if the last row has an image with the source s.gif. I believe this is a spacer (it is 0px × 10px), or maybe it’s some sort of tracker?

Regardless, this was the best approach I could come up with to identify the footer. Wild.

I could probably use shorten the selector, but since I need verbose selectors for other elements, I opted to be consistent and have most selectors be as specific.

User-Agent Styles Specificity

Let’s take a quick detour and explore something you probaby didn’t know: “page styles” will overrule any “user agent styles”.

No matter how specific your user agent styles are, the simplest CSS from the page will always overrule it. Unless you use !important.

However, within the “user agent styles” themselves, normal CSS specificity rules apply.

Try disabling the CSS rules below and see what background color you get. Did that match your expectations?

-- CSS on page

table {
  
}

-- User Agent Styles

table#mytable {
  
}

#mytable {
  
  
}

Table output:

heading 1heading 2
some datasome data
some datasome data

The table will be green. If we remove the CSS from the page, the table will be red. If we are to change the user agent style to background: cadetblue !important;, the table will be blue.

Dark and Light mode

I often end up switching between system light and dark mode. It was important to me that the color changes respected the original design, so I tried my best to do so. I am pretty happy with the result!

Here are a few comparisons.

Dark and Light mode comparison

Highlighting

I think that just the spacing is good enough for most people. However, since I was already fiddling with the code, I opted sprinkle in some JavaScript to highlight trending links.

Very simple: parse each link’s score and compare to a threshold. The thresholds are >=1000 and >=500.

Links with different highlighting

In case you don’t want to scoll all the way up again to toggle dark mode in the previews, here is the toggle again:

I previously had three thresholds (>=1000, >=600, and >=200), but I found myself subconsciously skipping any links below 200 points, which is not what I want. So I removed the lowest threshold and decreased the new lowest threshold to >=500.

Code

If you have your own way of injecting custom CSS and JS, feel free to remove the !important, as it should work just as fine.

CSS
:root {
  color-scheme: light dark;

  --hn-content-spacing: 12px;
  --hn-header-background: #ff6600;
  --hn-content-background: #f6f6ef;
  --hn-primary: black;
  --hn-secondary: #828282;
}

#hnmain {
  min-width: 200px;
}

@media (prefers-color-scheme: dark) {
  :root {
    background: #000;
    --hn-header-background: #f06000;
    --hn-content-background: #111;
    --hn-primary: #d3d3d0;
  }
}

/* colors of everything (dark+light) */
a:link, .c00, .admin td {
  color: var(--hn-primary) !important;
}

.comhead a, .subtext a {
  color: var(--hn-secondary) !important;
}

#hnmain {
  background: var(--hn-content-background) !important;
}

/* fix spacing on *almost* small viewports */
@media (max-width: 795px) {
  #hnmain {
    width: 100% !important;
    min-width: unset !important;
  }
}

/* spacing */
body {
  margin: 0;
}

#pagespace,
.spacer {
  display: none !important;
}

/* hide ads */
/*
tr.athing:not(:has(.votelinks)) {
  display: none;

  & + tr,
  & + tr + tr {
    display: none;
  }
}
#hnmain > tbody > tr:nth-of-type(4) > td > center:first-of-type:not(:last-of-type) {
  display: none;
}
*/

/* header */
#hnmain > tbody > tr:nth-child(1) > td {
  /* remove background color, so we can add padding for spacing */
  background: none !important;

  & a {
    /* ensure all links stay black */
    color: black !important;
  }

  & > table {
    border-spacing: 8px !important;
    background-color: var(--hn-header-background) !important;
    margin-block-end: calc(var(--hn-content-spacing) / 2) !important;

    /* ensure current date or title (if present) is not wrapped and has spacing */
    & > tbody > tr > td:nth-child(2) > span.pagetop > font {
      white-space: nowrap;
      line-height: 1.5;
    }

    /* do not wrap user / logout */
    & > tbody > tr > td:last-child > .pagetop {
      white-space: nowrap;
    }
  }

  /* trunc… longer usernames */
  & a#me {
    display: inline-block;
    max-width: 200px;
    overflow: hidden;
    text-overflow: ellipsis;
    vertical-align: middle;
  }
}

/* footer */
/* only footers have that weird spacing gif */
#hnmain > tbody > tr:last-of-type > td:has(img[src="s.gif"]) {
  /* remove everything except the footer content (spacing and jank separator) */
  & > :not(:last-child) {
    display: none;
  }

  & > :last-child {
    border-top: 2px solid var(--hn-header-background);
    padding-block-start: var(--hn-content-spacing);
    margin-block-start: var(--hn-content-spacing);
  }
}

/* content */
#hnmain > tbody > tr:nth-child(3) > td > table {
  /* make links use all available space */
  width: 100%;

  /* use as little space as possible for ranking and voting columns */
  & tr.athing > td:nth-of-type(-n+2) {
    width: 0;
  }

  /* leading spacing for each row */
  & tr.athing > td:nth-of-type(1) {
    padding-inline-start: 8px !important;
  }

  /* vertical spacing for rows, split up into two parts: */
  /* add top spacing for first row … */
  & tr.athing:not(.comtr) > td {
    padding-block-start: calc(var(--hn-content-spacing) / 2) !important;
  }

  /* … and add bottom spacing for the following row */
  & tr.athing:not(.comtr) + tr > td {
    padding-block-end: calc(var(--hn-content-spacing) / 2) !important;
  }
}

/* single post / comment */
table.fatitem {
  /* highlighting breaks up on borders, so remove those */
  border-spacing: 0;
}

/* comments (/newcomments) */
/* the only way to see if we're on the comments section is the check the title of #pagespace. yuck. */
#pagespace[title='New Comments'] + tr > td > table > tbody > tr > td {
  padding-block: var(--hn-content-spacing) !important;
}

/* comment threads (/threads?id=…, comments in /item?id=…) */
tr.athing.comtr > td > table {
  padding-block: calc(var(--hn-content-spacing) / 4) !important;
}

/* specifically for highlighting */
:root {
  /* --hn-nice: #e7e7e0; */
  --hn-cool: #dfdbcc;
  --hn-epic: wheat;
}

@media (prefers-color-scheme: dark) {
  :root {
    /* --hn-nice: #21201e; */
    --hn-cool: #332e2a;
    --hn-epic: #41301f;
  }
}

tr.hn-cool,
tr.hn-cool + tr {
  background: var(--hn-cool);
}

tr.hn-epic,
tr.hn-epic + tr {
  background: var(--hn-epic);
}
JS
document.addEventListener('DOMContentLoaded', () => {
  document.querySelectorAll('span.score').forEach(e => {
    let score = Number(e.innerHTML.split(" ")[0])
    let scoreClass = undefined
    if (score >= 1000) {
      scoreClass = 'epic'
    } else if (score >= 500) {
      scoreClass = 'cool'
    }

    if (scoreClass) {
      e.parentElement
        .parentElement
        .parentElement
        .previousElementSibling
        .classList.add('hn-' + scoreClass)
    }
  })
})

If you use Arc broweser, and don’t want highlighting, you can get this Boost with this link: Prettier Hacker News

Closing thoughts

I am quite happy with the result. I think it I managed to improve the readability of the website while staying true to the original design.

It was hard to navigate the wild HTML structure, where the pages differ quick wildly, but in certain cases have almost the exact same structure with completely different content.

Truly, Amazon.com and Hacker News are proof you don’t need good markup to run a successful website.