multiple polaroid photos

Why I create presentations in HTML

Since a year, I don't use PowerPoint anymore. I instead enjoy using HTML and CSS to create my slides with reveal.js.

In this blog post, I want to share my experience and tips. This is not a tutorial, so please refer to the reveal.js docs to find out more on how to use it. If you are interested in what the result looks like, check out my latest talk from APEX World 2025: APEX UX: treat your users as you like to be treated. Some people asked me what I use to create my slides, so I thought I should blog about it.

Why?

In short:

  • Versioning
  • Minimalism
  • Productivity

At my previous job, we had a corporate PowerPoint template that we had to use. At United Codes, we don’t have one, so I thought to use my freedom to try out something new.

Versioning

I switched because I was in favor of using a text file, which I can easily version. I usually do talks at multiple conferences, so they naturally evolve. With Git, it is super easy to do commits to keep track of the changes.

After a conference, I can now save the state with a Git tag and start changing the slides with my adjustments. Before, I always had plenty of copies of the same presentation.

Productivity and Minimalism

I like to keep things simple. Presentations should mainly focus on the content. I am not Apple, where the presentation is so well crafted that it is entertainment by itself. The people come to my presentation because they would like to learn something. So I want to spend my time on the content and not on aesthetics.

PowerPoint is a mighty tool. You have so many tools to change any aspect of typography, add 3D effects to text, add shadows and reflections to images, animate things, and so on. But does that really make the presentation better? Many corporate templates are so visually overloaded with every detail of the branding that they are distracting.

In VS Code, where I edit my slides, I don’t have a what you see is what you get editor. I have a simple text file with HTML and CSS and a browser that live reloads to show me the output. I can change text size and line height pretty easily by adding classes. Other than that, I have a base CSS file that gives me a nice and clean look and feel. This is enough to make my slides look appealing.

I noted that I am much more productive with that approach. I don’t worry about making things pixel perfect or playing around with too many options. My code editor is distraction-free, and I can just write down the content.

How does the HTML look?

With reveal.js, each slide is a <section>. Use <h2> for the title. The content will mostly be <div>’s, <p>’s and <ul>’s. For media use, <img>’s, etc. For common styles, I use classes like flex (display next to each other), line-height-2xl and font-2xl. I even implemented Oracle APEX Universal Theme classes like u-danger-text to highlight important things. Specific styles, like image width, can just be added inline.

<section>
  <h2>
    <span class="u-danger-text">Don't</span>: new page → add to navigation
  </h2>
  <div class="uc-slide-content line-height-2xl">
    <div class="font-2xl flex">
      <img
        style="max-height: 60%; width: 20%;"
        loading="lazy"
        src="./imgs/bad-navigation.avif"
      />
      <div>
        <ul>
          <li>
            This is just overwhelming, users will scroll this a lot during the
            day
          </li>
          <li>New users need to find their way around</li>
          <li>Existing users want to access things quickly</li>
        </ul>
      </div>
    </div>
  </div>
</section>
Screenshot of a slide with a title and an image on the left and text on the right. The title is big and bold where the word 'Don't' is red. The image shows an APEX navigation menu with lots of items. The text on the right is an unordereed list where the text is black and the bullets are gray.

Don’t get lost with big presentations

I use these <!-- region xyz --> comments to wrap chapters:

<!-- region app-structure -->

<section>...</section>
<section>...</section>
<section>...</section>

<!-- #endregion -->

Then, in combination with the Region Viewer extension, I can easily jump between the chapters to never get lost in the presentation.

Screenshot of my VS Code with a right panel listing regions. I can click on them to jump to the region.

Snippets

I maintain a set of snippets for common slide types to quickly create new slides. Then I don’t need to remember HTML structures and save time. I just use Alfred (macOS spotlight replacement) and type sn {slide type} and get the markup pasted in my editor. These are the snippets I use:

  • Agenda Slide
  • Chapter Slide
  • Code Slide
  • Image Slide
  • Image and Text Slide
  • List (ul) Slide
  • Table Slide
  • Title Slide

Benefits of having HTML slides

I often use demos in my presentations. With PowerPoint, it is always a hassle to exit the presentation mode, switch to the right browser window, and get back to the presentation again. With HTML slides, I already present them in the browser, and thus I can just switch to a tab with the demo. When I have links in my presentation, I can just click on them, and they immediately open in a new tab.

Additionally, reveal makes the slides look great on any device. It allows you to swipe on touchscreens and even has a scroll-down mode for vertical phones, making it appear like a fancy page.

With internal linking, I can easily link to other slides. I added an event listener for the Shift + A key combination to jump to the agenda slide, where you can then jump to each chapter via a link. Reveal also allows you to press Esc to zoom out a bit and scroll through the slides from an overview.

document.addEventListener("keydown", (event) => {
  if (event.shiftKey && event.code === "KeyA") {
    Reveal.slide(2); // Jump to the agenda slide index
  }
});

At last, I like that my slides are in a structured format. You can easily copy and paste the content, and probably it has better accessibility and is indexable by search engines.

More tips

At last some uncategorized tips that help me with my slides:

With the LanguageTool Linter extension, I can check my slides for spelling and grammar mistakes.

If somebody needs a copy of my slides in the PDF format, I can add ?print-pdf to the URL and then use the browser print dialog to create a PDF without issues. If the PDF is too big, I can compress it with GhostScript: gs -sDEVICE=pdfwrite -dCompatibilityLevel=1.4 -dPDFSETTINGS=/ebook -dNOPAUSE -dQUIET -dBATCH -sOutputFile=output.pdf input.pdf.

To minimize image size, I convert all images to the AVIF format with this command: bunx avif --input "./imgs/*.{jpg,png}" --quality 80 --effort 8 --overwrite && rm -r ./imgs/*.png || true && rm -r ./imgs/*.jpg || true (requires Bun).

Download new languages for syntax highlighting from highlightjs.org. Add them to /plugin/highlight/languages. The SQL highlighter also does a fantastic job for Oracle PL/SQL.

I can easily just copy the whole file, paste it into Claude (or any other LLM), and ask for feedback and parts that could be better phrased. (Note that I don’t let Claude write my slides; I just use it to get feedback.)

Conclusion

This is probably not for everyone. If you are productive with PowerPoint, then stick to it. For me, this approach worked out great, and I am super happy with it. My main idea to move to this approach was version control. Now I most like how my slides are more content-driven now and how I am more productive.

Updates

August 2026

Over a year and a lot of talks later, a few things in my setup have changed. Two of them come up often enough that they deserve their own section: how my bullet slides pick their own font size, and how I hand a deck to somebody who asks for it.

Jumping to the agenda without hardcoding a slide index

I actually now use a smarter approach to let me immediately get to the agend slide:

document.addEventListener("keydown", (event) => {
  if (event.shiftKey && event.code === "KeyA") {
    const agendaSlide = document.getElementById("agenda");
    if (agendaSlide) {
      const agendaSlideIndex = Reveal.getIndices(agendaSlide).h;
      Reveal.slide(agendaSlideIndex);
    } else {
      console.warn("Slide with ID 'agenda' not found.");
    }
  }
});

Looking it up by id survives reordering.

The agenda slide advertises both shortcuts itself, in a span with a no-print class so the hint doesn’t end up in the PDF:

<section id="agenda">
  <h2>Agenda</h2>
  <span class="no-print" style="text-align: left; font-size: 1rem;">
    (Shift + A: jump to Agenda)
  </span>

  <div class="uc-slide-content">
    <ul>
      <li><a href="#/intro">Introduction</a></li>
      <li><a href="#/llms">LLMs</a></li>
      <li><a href="#/prompting">Prompting</a></li>
      <li>Q&amp;A</li>
    </ul>
  </div>
</section>

The agenda entries are real links to named slides. Each chapter slide gets an id and the agenda links to #/that-id, which works because I have hash: true in my Reveal.initialize. So the agenda is a clickable hub with no JavaScript at all, and Shift + A gets me back to it. During a long workshop, I can still comfortably navigate around

List slides that size their own text

Earlier in this post I wrote that I change text size and line height by adding classes like font-2xl and line-height-2xl. That was true, and it was also the one part of my workflow that felt like the pixel-fiddling I moved away from PowerPoint to avoid. A slide with three bullets and a slide with six need different type sizes, and I was picking them by eye, slide by slide.

Now I write the markup without any size hint at all:

<section>
  <h2>Themes</h2>
  <div class="uc-list-slide">
    <ul>
      <li>Extracting value from your unstructured data using LLMs.</li>
      <li>Automation</li>
      <li>Enabling new interfaces</li>
    </ul>
  </div>
</section>

It works in two halves. A bit of JavaScript measures how much text is on the slide and writes the number into a CSS variable:

document.addEventListener("DOMContentLoaded", () => {
  document.querySelectorAll(".uc-list-slide").forEach((slide) => {
    let count = 0;
    slide.querySelectorAll("li").forEach((item) => {
      const lineHeight = parseFloat(window.getComputedStyle(item).lineHeight);
      const lines = Math.max(1, Math.round(item.offsetHeight / lineHeight));
      count += lines;
    });
    slide.style.setProperty("--count", count);
  });
});

The important part is that it counts rendered lines, not list items. A three-bullet slide where one bullet wraps onto three lines counts as five, so a slide with few but long bullets still gets sized down. Dividing offsetHeight by lineHeight only works as a line count because the CSS below sets line-height to the same value as font-size, so one line is always exactly one line-height tall.

Then the CSS divides the available height by that count:

.uc-list-slide > ul {
  --min-fs: 2rem;
  --max-fs: 3.3rem;
  --text-space: 77cqh;
  --fs-factor: 0.35;

  display: flex;
  flex-direction: column;
  justify-content: space-evenly;
  height: 100%;

  font-size: clamp(
    var(--min-fs),
    calc(var(--text-space) / var(--count) * var(--fs-factor)),
    calc(var(--max-fs) * (1 - (var(--count) * 0.02)))
  );
  line-height: clamp(
    var(--min-fs),
    calc(var(--text-space) / var(--count) * var(--fs-factor)),
    calc(var(--max-fs) * (1 - (var(--count) * 0.02)))
  );
}

The three arms of the clamp() each do one job:

  • the floor stops a very long list from shrinking into nothing
  • the middle is the actual calculation: available height divided by the number of lines, scaled down a bit so lines don’t touch
  • the ceiling caps the size, and because it is --max-fs * (1 - --count * 0.02) it also tapers as the list grows, so a two-bullet slide gets big type without looking silly

I like that the JavaScript never sets a font size. It only measures and reports; the stylesheet decides.

When I do want to override it, I add a size class that changes nothing but the variables:

.uc-list-slide.lg ul {
  --min-fs: 2.25rem;
  --max-fs: 4.25rem;
  --fs-factor: 0.38;
}

There are xs, sm, lg and xl. But I actually end up never using the variants as the normal one works well in most cases.

Shipping a deck as a single HTML file

Above I mentioned exporting a PDF when somebody wants my slides. That is fine as an archive, but the PDF loses the links, the fragments and a bit of the layout. What I actually want is to hand over something that still is the presentation.

I tried two things before. Running bunx html-inline over each deck and copying the imgs folder next to the output, and mirroring the whole thing into a flat folder with reveal’s reveal.js, reveal.css and plugins copied alongside. Both work, but both mean carrying a folder around.

The mirroring approach also had a subtle problem worth knowing about: it rewrote every agenda link from href="#/intro" to href="index.html#/intro". In a browser that still goes to the right slide, but as a full page reload instead of a reveal transition. It quietly broke the clickable agenda from the section above.

So I wrote a script that produces exactly one self-contained HTML file per deck:

bun run share                              # default target
bun run share 2026-ai-workshop             # a whole folder
bun run share 2025-security/index.html     # a single deck
bun run share 2026-ai-workshop 2025-uc-ai  # several at once

What it does:

  • <link rel="stylesheet"> becomes an inline <style>, and url(...) references inside that CSS are resolved relative to the stylesheet and embedded too
  • <script src> becomes an inline script, kept as text rather than base64 so the output stays readable
  • src, data-src, data-background-image, data-background-video and poster become data URIs — data-src matters because that is reveal’s lazy loading attribute
  • HTML comments are dropped
  • file names stay the same and everything lands in one flat folder, so links between the decks of a workshop keep working
  • it never touches an href, so the agenda links stay reveal navigations
  • it writes an index.html that links all the decks
  • per deck it prints the size and how many stylesheets, scripts, assets and comments it handled, so I notice when something did not get inlined

The comment stripping is the part I would not want to be without. All those <!-- region xyz --> markers from earlier in this post, plus every slide I commented out instead of deleting and every note to myself, would otherwise travel to whoever gets the file. Stripping runs before the inlining, so an image that only a commented-out slide references never gets embedded as a data URI either:

function stripHtmlComments(html) {
  const blocks = /<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi;
  let count = 0;

  const strip = (text) =>
    text.replace(/<!--([\s\S]*?)-->/g, (match, body) => {
      if (/^\[if\b/i.test(body.trim())) return match;
      count++;
      return "";
    });

  const parts = [];
  let last = 0;
  let block;
  while ((block = blocks.exec(html))) {
    parts.push(strip(html.slice(last, block.index)), block[0]);
    last = block.index + block[0].length;
  }
  parts.push(strip(html.slice(last)));

  return { html: parts.join(""), count };
}

It walks around <script> and <style> bodies, because a <!-- inside JavaScript or CSS is not an HTML comment, and it leaves IE conditional comments alone.

The other detail that cost me some time: inlining a script as text breaks if the JavaScript contains the string </script, because that ends the block early. Escaping it to <\/script is equivalent JavaScript and cannot appear outside a string or a comment:

function inlineScript(jsFile) {
  return fs.readFileSync(jsFile, "utf8").replace(/<\/script/gi, "<\\/script");
}

The output goes into a folder that gets wiped and rebuilt on every run, so I can zip it and send it. Each file opens with a double click — no server, no sibling asset folder, nothing else to copy along. This is also where the AVIF conversion from further up pays off twice, since every image ends up base64-encoded in the file.

Other Posts

Comments

Loading comments...
Homepage •All Blogposts

AI disclaimer: I spend hours writing my blog posts by hand, adding my own thoughts and experiences to them. In my view, purely AI-generated content lacks that human depth and isn't worth publishing. I only use AI for research and editing assistance.