DEV Community

Cover image for A football club website in plain HTML and Tailwind CSS 4, with a 44-line build script
Dan Fisher
Dan Fisher

Posted on AI-assisted

A football club website in plain HTML and Tailwind CSS 4, with a 44-line build script

Most football clubs I know run their website on whatever a volunteer set up years ago. So when I made a free template for them, I set two rules. It has to work as a folder of static files you can upload anywhere. And someone who knows a bit of HTML has to be able to change it without learning a framework.

The result is Golden Team, a one-page site for a made-up club: the next match, latest results, fixtures, the league table, player of the month, the squad, news, a join form and the club's partners, in light and dark mode.

These are the parts that took some thought.

Includes without a framework

A club page repeats itself a lot: four result cards, six fixture rows, eight player cards, each with different data. Copying the markup eight times is how templates rot, so the page is put together at build time from small pieces, by a Node script with no dependencies.

A page pulls in a piece with an HTML comment, and the attributes on the comment fill {{placeholders}} in the piece:

<!-- @include partials/result-card.html home="Golden Team" home-team="golden" home-score="2" away="Emerald Team" away-team="emerald" away-score="0" competition="Blocks League" date="7 Nov 2026" -->
Enter fullscreen mode Exit fullscreen mode

The renderer is one recursive function:

const INCLUDE = /<!--\s*@include\s+(\S+)((?:\s+[a-z-]+="[^"]*")*)\s*-->/g;

function render(file, vars = {}, depth = 0) {
  if (depth > 10) throw new Error(`Includes nested too deep at ${file}`);
  const html = readFileSync(join(SRC, file), 'utf8').replace(INCLUDE, (_, path, attrs) => {
    const local = { ...vars };
    for (const [, key, value] of attrs.matchAll(/([a-z-]+)="([^"]*)"/g)) local[key] = value;
    return render(path, local, depth + 1);
  });
  return html.replace(/\{\{([a-z-]+)\}\}/g, (_, key) => vars[key] ?? '');
}
Enter fullscreen mode Exit fullscreen mode

Values pass down through nested includes, so the result card can include the crest and hand it team="{{home-team}}". Adding a result is one line. npm run dev rebuilds on every save, with the Tailwind CLI watching alongside.

Because the includes are comments, every source file is still valid HTML, and an editor's HTML tooling works on it as usual.

The theme lives in CSS

Tailwind CSS 4 moved configuration into the stylesheet, which suits a template: there's no config file to explain. Colors, type sizes and the content width are all in src/css/input.css:

@import 'tailwindcss';

@custom-variant dark (&:where(.dark, .dark *));

@theme {
  --color-team-golden-primary: #ffba00;
  --color-team-emerald-primary: #00d3af;
  --color-team-crimson-primary: #ff2b4f;
  /* ...the other teams, grays and type sizes */
  --tracking-heading: -0.0125em;
}

@utility club-container {
  margin-inline: auto;
  width: 100%;
  max-width: 87.5rem;
  padding-inline: 1rem;
  @media (width >= 40rem) {
    padding-inline: 1.5rem;
  }
}
Enter fullscreen mode Exit fullscreen mode

Every team color becomes a set of utilities (text-team-golden-primary, bg-team-emerald-primary and so on), and club-container is the one column every section sits in. Changing the club color or the page width is a single edit.

Dark mode without a flash

The @custom-variant line above makes dark: follow a class on <html> instead of the system setting, so visitors can switch. To avoid a white flash on load, a few lines in <head> apply the saved choice, or the system setting, before the page paints:

<script>
  try {
    var theme = localStorage.getItem('theme');
    if (theme === 'dark' || (!theme && matchMedia('(prefers-color-scheme: dark)').matches))
      document.documentElement.classList.add('dark');
  } catch (e) {}
</script>
Enter fullscreen mode Exit fullscreen mode

The try is there because some browsers throw on localStorage with storage blocked. The toggle still works for that visit.

One crest for every team

Every team in the league shares one crest drawing in a different color. It's drawn once, as an SVG <symbol> filled with currentColor:

<svg class="invisible absolute" width="0" height="0" aria-hidden="true">
  <symbol id="crest" viewBox="0 0 420 420"><!-- paths --></symbol>
</svg>
Enter fullscreen mode Exit fullscreen mode

The crest partial points at it with <use> and picks the color with a class built from the include value:

<svg viewBox="0 0 420 420" class="{{class}} text-team-{{team}}-primary" aria-hidden="true">
  <use href="#crest" />
</svg>
Enter fullscreen mode Exit fullscreen mode

One catch: Tailwind only generates classes it finds written out in full, and text-team-{{team}}-primary isn't. It works here because every team's class also appears, spelled out, in the league table. If you add a team that isn't in the table, write its class out somewhere, or safelist the whole set in the CSS with @source inline("text-team-{golden,emerald,crimson}-primary");.

Fixture rows that fit their column

The fixture row has to work at full width on a desktop and in a narrow column on a phone, and anyone reusing it may drop it into a sidebar. Container queries let it respond to its own width instead of the viewport's:

<div class="@container ...">
  <div class="flex flex-col @2xl:flex-row ...">
    <!-- date and teams -->
    <div class="flex flex-col @min-[58rem]:flex-row ...">
      <!-- venue and channel -->
    </div>
  </div>
</div>
Enter fullscreen mode Exit fullscreen mode

Below 42rem the row stacks. From there the teams sit side by side, and from 58rem the venue and channel join them on one line. Team names switch between full names and three-letter codes with the same container variants. In Tailwind CSS 4 all of this is built in: @container on the parent, @2xl: or an arbitrary @min-[58rem]: on the children.

The squad filter

The squad has tabs for goalkeepers, defenders, midfielders and forwards. The whole filter:

document.querySelectorAll('[data-filter-group]').forEach(function (group) {
  var buttons = group.querySelectorAll('[data-filter]');
  var items = group.querySelectorAll('[data-filter-item]');
  buttons.forEach(function (button) {
    button.addEventListener('click', function () {
      var value = button.getAttribute('data-filter');
      buttons.forEach(function (b) {
        b.setAttribute('aria-pressed', String(b === button));
      });
      items.forEach(function (item) {
        item.hidden = value !== 'all' && item.getAttribute('data-filter-item').split(' ').indexOf(value) === -1;
      });
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

The active tab is styled with Tailwind's aria-pressed: variant (aria-pressed:bg-custom-gray-900), so its state lives in one attribute, and screen readers announce the same attribute.

A scroll reveal that fails safe

Headings, cards and sections blur in as they scroll into view. The risk with reveal animations is content that stays invisible when something goes wrong, so hiding is opt-in. A line in <head> arms the reveal only when the browser has IntersectionObserver and the visitor hasn't asked for reduced motion, and switches it off again if the main script hasn't started within three seconds:

if ('IntersectionObserver' in window && !matchMedia('(prefers-reduced-motion: reduce)').matches) {
  document.documentElement.setAttribute('data-reveal', 'armed');
  setTimeout(function () {
    if (!window.clubRevealStarted) document.documentElement.setAttribute('data-reveal', 'off');
  }, 3000);
}
Enter fullscreen mode Exit fullscreen mode

The CSS only hides anything under :root[data-reveal='armed'] (simplified):

:root[data-reveal='armed'] [data-reveal='scroll'] {
  opacity: 0;
  filter: blur(12px);
  transform: translateY(16px);
  transition:
    opacity 0.7s ease,
    filter 0.9s var(--ease-reveal),
    transform 0.9s var(--ease-reveal);
  transition-delay: var(--reveal-wait);
}

:root[data-reveal='armed'] [data-reveal='scroll'][data-shown] {
  opacity: 1;
  filter: none;
  transform: none;
}
Enter fullscreen mode Exit fullscreen mode

An IntersectionObserver sets data-shown once and stops watching that element. Cards in a grid get a stagger index from a data-reveal-group attribute, and headings can blur in word by word. Without JavaScript nothing is ever armed, so everything shows.

One lesson from this part. The hero has half a football pitch behind it, and its grass texture started as a live SVG feTurbulence filter. It looked fine standing still, but every animation on top of it made the browser redraw the noise. In Chrome's screencast the hero managed 9 frames in 2.3 seconds while the reveal played. The texture is now a pre-rendered 256-pixel noise tile (22 KB of WebP) used as an SVG <pattern>. It looks the same, and the same recording gives 75 to 81 frames with no stalls.

A wordmark that always fills the column

The footer ends with the club name across the full width. With HTML text, how wide that comes out depends on which font loaded. In SVG, textLength pins it:

<svg viewBox="0 0 571.4 76" class="block h-auto w-full">
  <text x="0" y="75" font-size="100" font-weight="900" textLength="571.4" lengthAdjust="spacing">Golden Team</text>
</svg>
Enter fullscreen mode Exit fullscreen mode

The SVG scales with the column, and lengthAdjust="spacing" stretches or squeezes only the gaps between letters, so the name always ends exactly at the edge.

What's in the download

  • dist/: the finished site. Open index.html, or upload the folder anywhere.
  • src/: the pieces, the build script and the Tailwind entry.
  • README.md: what to change first, from the club name, crest and colors to the next match and the forms.
  • LICENSE.md: free for unlimited personal and commercial sites, client work included. You just can't pass it on as a template.

The compiled CSS is 47 KB minified, about 9 KB gzipped. The forms are front end only, so point their action at your own service. The player photos, news photos and partner logos are AI-generated stand-ins for fictional people and companies, there to be replaced with your club's own.

Full disclosure: I make SportyBlocks, a paid library of sports UI components for Tailwind CSS. The template is built from six of its components (Scoreboard, Match Schedule, Standings, Head-to-Head, Player Stats and Player Card), and it's free, with no paid version.

If you build a site for a club with it, I'd like to see it. And if your club needs something it doesn't have, say so in the comments.

Top comments (0)