All posts Software

JavaScript Without a Framework: What Building This Site Taught Me

There is not a single JavaScript library on this site. I walk through applying the theme before the first paint, IntersectionObserver, event delegation, and the scroll bug I found in my own code.

DT
Demir Taşdemir Mobile App & Web Developer
— min read

The site you are reading right now does not contain a single JavaScript library. There is no build step either, and no package manager. What sits on the server is a handful of HTML files, one CSS file and two JavaScript files: main.js, and blog.js which powers the blog pages. This post is about what I learned writing those two files, and about a bug I found in my own code along the way.

Why I did not reach for a framework

The decision was not ideological. This site is mostly text: a few static pages, a project list and a blog. There is no state shared between screens, no data streaming in from a server continuously. For a job like that, adding React brought in more moving parts than it removed.

The second reason is more personal. On the mobile side I have been writing Swift, Java and Kotlin for three years; there you have to learn the platform's own APIs, with nothing in between to shield you. I wanted the same thing on the web: to see for myself what the browser actually gives me. When a behavior breaks, I can open the source file and read it, instead of the layer sitting on top of it.

This decision has a price, and I will get to it at the end of the post. First, the parts that worked.

The screen flashing on theme switch

The site's default theme is white. If a visitor switches to dark, the preference is stored in localStorage under the dt-tema key. The first time I wrote it, I put the theme-applying code inside main.js, and anyone who had chosen the dark theme saw a white flash every time they opened a page.

The reason is simple: main.js runs at the end of the page, after DOMContentLoaded. By that point the browser has already painted the page once. So the white screen really is drawn, and then the dark theme lands on top of it. This is called FOUC, and the fix is to apply the theme before the first paint.

That is why every HTML file has a small inline script in its section, running even before the stylesheet:

<head>
  <script>
  (function () {
    try {
      var kayitli = localStorage.getItem('dt-tema');
      document.documentElement.dataset.theme = kayitli || 'light';
    } catch (e) { /* private mode: the default light theme stays */ }
  })();
  </script>

  <link rel="stylesheet" href="css/style.css">
</head>

This script is inline on purpose, and blocking on purpose. As a separate file it would mean waiting on an extra request; with defer it would run too late anyway. Since it is only a few lines, the blocking cost is negligible. The critical part is that it writes the data-theme attribute onto the tag: the CSS variables hang off that attribute, so the first paint happens with the right colors straight away.

The try/catch is not decoration. In some private-tab and cookie-restricted situations, reading localStorage throws. If you do not catch it, that exception kills the script and data-theme never gets written at all. While applying the theme I also update the meta[name="theme-color"] tag and the documentElement.style.colorScheme value; the second one is what makes the browser draw the scrollbar and form controls in the right color.

The same script repeats in every file

Those five lines sit as a copy in all of the nearly thirty HTML files. This is the most visible line item on the bill for not using a framework, and I will come back to it at the end of the post.

Doing scroll animation with an observer instead of a scroll listener

The sections on the page fade in gently as they enter the viewport. The first approach that came to mind was the classic one: attach a scroll listener, read each element's getBoundingClientRect() on every event, and add a class if it is visible. That approach works, but it keeps measuring on the main thread for the whole length of the scroll.

IntersectionObserver flips the relationship around: you do not ask, the browser tells you.

const ogeler = document.querySelectorAll('[data-reveal]');

// Reduced motion is on, or the API is missing: make everything visible right away
if (azHareket || !('IntersectionObserver' in window)) {
  ogeler.forEach((el) => el.classList.add('is-visible'));
  return;
}

const gozlemci = new IntersectionObserver((girisler, gzl) => {
  girisler.forEach((giris) => {
    if (!giris.isIntersecting) return;
    giris.target.classList.add('is-visible');
    gzl.unobserve(giris.target);   // it appeared once, no need to keep listening
  });
}, { threshold: 0.12, rootMargin: '0px 0px -60px 0px' });

ogeler.forEach((el) => gozlemci.observe(el));

Three details here shaped the rest of the code. The first is unobserve: if the animation only plays once, there is no point leaving the observer attached. The second is the prefers-reduced-motion check; for a user sensitive to motion the animation should be turned off, but the content must not disappear with it. The third is the fallback path: if the API is not supported, I make everything visible.

That third item matters, because the CSS side of the reveal animation starts the element at opacity: 0. If JavaScript fails to run for any reason, the page is left completely empty. The rule is this: fallback behavior must always make content visible, never leave it hidden. For the same reason, the real values of the numbers on the home page are written directly in the HTML; the counter animation only writes over them. With JavaScript disabled, and when a search engine crawls the page, the correct figure is still there.

I mark the active link in the menu with the same API, but with a different setting: rootMargin: '-45% 0px -50% 0px'. That value crops the viewport from the top and the bottom, leaving a thin band across the middle of the screen. Whichever section touches that band is "the section being read right now". You can see the result directly by playing with the percentages; for me that turned out to be far more understandable than trying to compute scroll positions by hand.

The bug I found in my own code

While preparing this post I read main.js from the top and found something I needed to fix. The function that updates the reading progress bar at the top reads document.documentElement.scrollHeight on every scroll event, and immediately after that writes the bar's style.width.

The problem is this: scrollHeight is a layout read, while writing a width invalidates layout. Repeat that dozens of times per second and you force the browser to recalculate for no reason. On top of that, the page height does not change during scrolling anyway. The right approach is to tie the update to the frame and refresh the measurement only at the moment it can actually change:

let bekliyor = false;

window.addEventListener('scroll', () => {
  if (bekliyor) return;                 // there is already work queued for this frame
  bekliyor = true;
  requestAnimationFrame(() => {
    guncelle();
    bekliyor = false;
  });
}, { passive: true });

// For work that needs measuring: run once, 150 ms after the last event
function geciktir(fn, ms = 150) {
  let zaman;
  return function (...args) {
    clearTimeout(zaman);
    zaman = setTimeout(() => fn(...args), ms);
  };
}

window.addEventListener('resize', geciktir(olcumleriYenile, 150));

With requestAnimationFrame there is at most one update per frame; the screen is not painted more often than that anyway. On the resize side, debounce logic is what helps, because dragging a window produces a flood of events and all we need is the final measurement after the drag ends.

The one thing I had gotten right was adding { passive: true }. That tells the browser up front that the listener will not call preventDefault(), so the browser can keep scrolling without waiting on JavaScript's decision.

Event delegation: how many listeners to attach

Right now I attach a separate click listener to every link in the menu and to every filter button. For a handful of elements that is perfectly reasonable; delegation is not a rule, it is a matter of balance.

But there is one case where delegation stops being a preference and becomes a necessity: content that enters the page later. The "related posts" block at the bottom of blog articles is generated after fetching the blog listing with fetch and parsing it with DOMParser. Those elements are not there at DOMContentLoaded; a listener attached at that moment will never reach them. I got lucky there, because what gets generated is a plain link and needs no listener. For the copy buttons I add to code blocks, though, I had to attach the listener on the same line where I create the button. Delegation solves both with a single rule: attach the listener to a parent element that always exists, and find the real target at event time.

document.querySelector('.filtre-serit').addEventListener('click', (e) => {
  const btn = e.target.closest('.filter');
  if (!btn) return;                 // the click landed in the gap between the buttons
  uygula(btn.dataset.filter);
});

The real lesson here is on the closest() side. At first I had written e.target.dataset.filter, and the button sometimes did not respond. The reason: e.target is the deepest element that was clicked. If the button contains an icon and the user clicks right on the icon, e.target is that svg, not the button. closest() climbs upward and finds the real button. A button that works when you click its edge but not its center is the classic symptom of this bug.

Turkish search and the "İ" problem

The search box on the blog listing turned up an unexpected problem: typing "istanbul" did not find a post containing "İSTANBUL". The reason is that 'İ'.toLowerCase() produces not a single character but an "i" plus a separate combining dot mark. On screen it looks like an "i", but it does not match in a comparison.

function sadelestir(metin) {
  const harita = { 'ç':'c','ğ':'g','ı':'i','ö':'o','ş':'s','ü':'u',
                   'İ':'i','I':'i' };
  return String(metin)
    .replace(/[çğıöşüİI]/g, (h) => harita[h] || h)   // dotted İ and uppercase I first
    .toLocaleLowerCase('tr')
    .replace(/[çğıöşü]/g, (h) => harita[h] || h)     // clean up what came back from lowercasing
    .trim();
}

The reason it happens in two stages is this: if the dotted uppercase İ is not caught before the lowercasing step, the combining dot is left behind. The remaining Turkish letters, on the other hand, are simplified after lowercasing. I run both the cards' text and whatever the user types through the same function, so the two sides meet in the same alphabet.

There is a small tweak on the performance side too: I compute each card's searchable text once at startup and store it on the element. Otherwise every keystroke would mean re-reading the textContent of every card. The cards also have a data-ara attribute; I put words there that do not appear in the title but that people are likely to search for.

Accessibility: the work a framework does not do for you

The menu button is a real

DT

Demir Taşdemir

Mobile App & Web Developer

I have been building software since 2018. I have published 11 apps on the App Store and Google Play; right now I am working on 6 mobile apps, 1 e-commerce platform and 1 desktop game.

Need a light and fast site?

I build fast, accessible and search-engine-friendly sites without a framework stack. Tell me about your project and let's look at how to set it up together.