<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Artclick</title>
    <description>The latest articles on DEV Community by Artclick (@_artclick).</description>
    <link>https://dev.to/_artclick</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4049105%2Fb5fe7e99-48a2-4a16-800c-90c6bd8cb07b.png</url>
      <title>DEV Community: Artclick</title>
      <link>https://dev.to/_artclick</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/_artclick"/>
    <language>en</language>
    <item>
      <title>You Don't Need a CSS Framework in 2026 — the Platform Caught Up</title>
      <dc:creator>Artclick</dc:creator>
      <pubDate>Mon, 03 Aug 2026 11:07:54 +0000</pubDate>
      <link>https://dev.to/_artclick/you-dont-need-a-css-framework-in-2026-the-platform-caught-up-51pa</link>
      <guid>https://dev.to/_artclick/you-dont-need-a-css-framework-in-2026-the-platform-caught-up-51pa</guid>
      <description>&lt;p&gt;Here's the take: if you're reaching for a CSS framework in 2026 out of habit rather than a specific, named requirement, you're probably shipping more code than the problem needs. Not because frameworks got worse — because the platform quietly closed almost every gap they existed to patch.&lt;/p&gt;

&lt;p&gt;This isn't "frameworks are bad." Bootstrap, Tailwind, and friends were the right call for most of the last decade, because CSS genuinely couldn't do the things they made easy. That's the part that's changed. Grid systems, component-level breakpoints, conditional styling, specificity control, color theming — these used to require a framework or a preprocessor because CSS itself had no answer. It has answers now, and most teams haven't gone back to check.&lt;/p&gt;

&lt;h2&gt;
  
  
  What frameworks were actually solving
&lt;/h2&gt;

&lt;p&gt;To make the case fairly, it's worth naming what problem each piece of a typical framework was actually there for — because "just use vanilla CSS" has been bad advice for a decade, and I want to be specific about why it stopped being bad advice rather than just asserting it.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What you reached for&lt;/th&gt;
&lt;th&gt;What it was actually working around&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A 12-column grid system&lt;/td&gt;
&lt;td&gt;CSS had no native concept of "columns that adapt to available space"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sass nesting and variables&lt;/td&gt;
&lt;td&gt;Flat CSS selectors got repetitive fast; no native scoping&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Utility classes (&lt;code&gt;.mt-4&lt;/code&gt;, &lt;code&gt;.flex&lt;/code&gt;, &lt;code&gt;.text-center&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Writing semantic class names for every one-off style was slow, and specificity was hard to manage at scale&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A breakpoint mixin (&lt;code&gt;@include md { ... }&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Media queries only ever knew the viewport, never the component's actual available space&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A theme/color system&lt;/td&gt;
&lt;td&gt;No native way to compute color variants (tints, shades, mixes) without precompiling every value&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;.card:hover .card-title { ... }&lt;/code&gt;-style JS toggles&lt;/td&gt;
&lt;td&gt;CSS couldn't style a parent based on what's inside it&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every row in that table used to be true. None of them are anymore.&lt;/p&gt;

&lt;h2&gt;
  
  
  Grid and subgrid replace the 12-column system
&lt;/h2&gt;

&lt;p&gt;CSS Grid has been supported everywhere for years, but the part that actually finished the job — subgrid — is what let go of the last reason to reach for a prebuilt column system. A grid of cards where each card's internal rows (image, title, meta, button) align across the whole row, not just within each card, used to need real work. Now it's a couple of lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.card-grid&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;grid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;grid-template-columns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;repeat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;auto-fit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;minmax&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;240px&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="n"&gt;fr&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="py"&gt;gap&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1.5rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.card&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;grid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;grid-template-rows&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;subgrid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;grid-row&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;span&lt;/span&gt; &lt;span class="m"&gt;4&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That single &lt;code&gt;auto-fit&lt;/code&gt;/&lt;code&gt;minmax()&lt;/code&gt; line does the job of an entire responsive column system — it reflows the number of columns based on available width with zero media queries and zero JavaScript resize listeners.&lt;/p&gt;

&lt;h2&gt;
  
  
  Container queries replace component-level breakpoint hacks
&lt;/h2&gt;

&lt;p&gt;This is the one that actually changes how you architect CSS, not just how you write it. A media query only ever knows the viewport. It has no idea if your card is in a wide main column or a narrow sidebar — which is exactly why component libraries ended up full of &lt;code&gt;.card--compact&lt;/code&gt; modifier classes that had to be manually applied wherever a component landed in a tight space.&lt;/p&gt;

&lt;p&gt;Container queries let the component ask about its own available space instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.card&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;container-type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;inline-size&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;@container&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;min-width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;380px&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nc"&gt;.card__body&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;grid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="py"&gt;grid-template-columns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;120px&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="n"&gt;fr&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="py"&gt;gap&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Drop that same card into a sidebar, a modal, or a full-width section, and it adapts to whichever container it's actually sitting in — no modifier class, no JavaScript &lt;code&gt;ResizeObserver&lt;/code&gt;, no knowledge of the page layout required at the component level at all. This is the single biggest reason a component library's "make it responsive" problem doesn't need a framework's help anymore.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;:has()&lt;/code&gt; replaces a surprising amount of your JavaScript
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;:has()&lt;/code&gt; lets a parent's style depend on what's inside it — something CSS flatly couldn't do before, which is why so many small interactions ended up as JavaScript that toggled a class on a parent element whenever a child changed state.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="c"&gt;/* Highlight a form field's wrapper only when it contains an invalid input */&lt;/span&gt;
&lt;span class="nc"&gt;.field&lt;/span&gt;&lt;span class="nd"&gt;:has&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;:invalid&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;border-color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--color-danger&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;/* Style a card differently if it happens to contain an image */&lt;/span&gt;
&lt;span class="nc"&gt;.card&lt;/span&gt;&lt;span class="nd"&gt;:has&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nt"&gt;img&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;grid-template-rows&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;auto&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="n"&gt;fr&lt;/span&gt; &lt;span class="nb"&gt;auto&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;/* Style a fieldset's label based on a checkbox elsewhere in the same fieldset */&lt;/span&gt;
&lt;span class="nt"&gt;fieldset&lt;/span&gt;&lt;span class="nd"&gt;:has&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nt"&gt;input&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nt"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;"checkbox"&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="nd"&gt;:checked&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nt"&gt;legend&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--color-accent&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Multiply that pattern across a real form or a component with a dozen conditional states, and &lt;code&gt;:has()&lt;/code&gt; is quietly deleting hundreds of lines of state-toggling JavaScript that never needed to exist once CSS could ask the question directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Native nesting replaces the entire reason most teams installed Sass
&lt;/h2&gt;

&lt;p&gt;If a project only used Sass for nesting and variables — which, honestly, describes most projects — there's no longer a reason to have a build step for it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.nav&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;flex&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="err"&gt;ul&lt;/span&gt; &lt;span class="err"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;list-style&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;flex&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="py"&gt;gap&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="err"&gt;li&lt;/span&gt; &lt;span class="err"&gt;a&lt;/span&gt; &lt;span class="err"&gt;{&lt;/span&gt;
      &lt;span class="nl"&gt;text-decoration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--color-text&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

      &lt;span class="err"&gt;&amp;amp;:hover&lt;/span&gt; &lt;span class="err"&gt;{&lt;/span&gt;
        &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--color-accent&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="err"&gt;}&lt;/span&gt;
  &lt;span class="err"&gt;}&lt;/span&gt;
&lt;span class="err"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's not a preprocessor output — it's what ships to the browser, unmodified. The CSS file structure mirrors the HTML structure, which was the actual appeal of Sass nesting the whole time. If your team's Sass usage is genuinely just this, the build step is now pure overhead: one more tool in the pipeline, one more thing that can break CI, for a feature the browser already has.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cascade layers replace the specificity war utility classes were built to dodge
&lt;/h2&gt;

&lt;p&gt;This is the one people miss. A big part of why utility-first frameworks feel necessary is that they sidestep CSS specificity entirely — every class has the same low specificity, so the last one written always wins, and you never fight &lt;code&gt;.header .nav ul li a&lt;/code&gt; versus &lt;code&gt;.nav-link&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;@layer&lt;/code&gt; gives you that same guarantee without giving up semantic class names:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="k"&gt;@layer&lt;/span&gt; &lt;span class="n"&gt;reset&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;components&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;utilities&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;@layer&lt;/span&gt; &lt;span class="n"&gt;base&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nt"&gt;a&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--color-text&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;@layer&lt;/span&gt; &lt;span class="n"&gt;components&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nc"&gt;.card&lt;/span&gt; &lt;span class="nt"&gt;a&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--color-accent&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;@layer&lt;/span&gt; &lt;span class="n"&gt;utilities&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nc"&gt;.text-muted&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--color-muted&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Layers are resolved in the order they're declared, regardless of selector specificity or source order — a single class in &lt;code&gt;utilities&lt;/code&gt; will always beat a deeply nested selector in &lt;code&gt;components&lt;/code&gt;, on purpose, without an &lt;code&gt;!important&lt;/code&gt; in sight. This was the actual argument for switching to utility classes: predictable override order. Cascade layers hand you that guarantee directly, so you can keep writing &lt;code&gt;.card-title&lt;/code&gt; instead of &lt;code&gt;.text-lg.font-bold.mb-2&lt;/code&gt; and still win the specificity fight when you need to.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;color-mix()&lt;/code&gt; and &lt;code&gt;oklch()&lt;/code&gt; replace the theme-generation layer
&lt;/h2&gt;

&lt;p&gt;Preprocessor color functions (&lt;code&gt;darken()&lt;/code&gt;, &lt;code&gt;lighten()&lt;/code&gt;, &lt;code&gt;mix()&lt;/code&gt;) existed because plain CSS could only store a color, not compute a new one from it. That's gone too:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nd"&gt;:root&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;--color-brand&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;oklch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;58%&lt;/span&gt; &lt;span class="m"&gt;0.18&lt;/span&gt; &lt;span class="m"&gt;250&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.button&lt;/span&gt;&lt;span class="nd"&gt;:hover&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;color-mix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;in&lt;/span&gt; &lt;span class="n"&gt;oklch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--color-brand&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="no"&gt;white&lt;/span&gt; &lt;span class="m"&gt;15%&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.button&lt;/span&gt;&lt;span class="nd"&gt;:active&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;color-mix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;in&lt;/span&gt; &lt;span class="n"&gt;oklch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--color-brand&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="no"&gt;black&lt;/span&gt; &lt;span class="m"&gt;15%&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;oklch()&lt;/code&gt; also happens to interpolate more predictably than hex or RGB — mixing two OKLCH colors doesn't produce the muddy, desaturated middle tones you get from mixing in RGB space, which was always the annoying part of building a tint/shade scale by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;clamp()&lt;/code&gt; replaces the typography-scale mixin
&lt;/h2&gt;

&lt;p&gt;This one you may already be doing — it's the same technique from fluid typography with &lt;code&gt;clamp()&lt;/code&gt;, but it's worth restating as part of the bigger case: a whole category of "responsive spacing/type scale" mixins collapses into single-property declarations:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nt"&gt;h1&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;font-size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;clamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;2rem&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;1.2rem&lt;/span&gt; &lt;span class="err"&gt;+&lt;/span&gt; &lt;span class="m"&gt;3vw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;3.5rem&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.section&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;padding-block&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;clamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;2rem&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;5vw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;6rem&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No breakpoint table, no mixin, no JavaScript recalculating on resize — the value scales continuously with the viewport and clamps at both ends.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where a framework still earns its keep
&lt;/h2&gt;

&lt;p&gt;None of this means frameworks are pointless now — it means their remaining justification is narrower and more specific than "CSS is hard," and worth naming honestly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Prebuilt, accessible interactive components.&lt;/strong&gt; A native &lt;code&gt;&amp;lt;dialog&amp;gt;&lt;/code&gt; or &lt;code&gt;&amp;lt;details&amp;gt;&lt;/code&gt; handles a lot, but a fully-featured date picker, combobox, or rich data table with proper ARIA behavior is still real work that a component library has already done correctly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enforced consistency across a large team.&lt;/strong&gt; A design system with strict, enforced tokens (via a utility framework's config) can genuinely prevent drift better than a style guide people are supposed to remember to follow — this is a people problem as much as a technical one, and utility classes are a decent people-problem solution.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Faster prototyping under real time pressure.&lt;/strong&gt; Reaching for known utility classes is still faster than making design decisions from scratch when you're throwing together an internal tool by Friday.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Legacy browser support.&lt;/strong&gt; If your analytics show a meaningful slice of traffic on genuinely old browsers, several of the features above aren't available to them, and a framework's fallback behavior earns its cost.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your actual situation is one of these, that's a real reason — not a rationalization. The point isn't "never use a framework." It's that "we've always used one" stopped being a technical reason sometime in the last two years, and it's worth checking which category your project is actually in.&lt;/p&gt;

&lt;h2&gt;
  
  
  A worked example: the thing frameworks were built for
&lt;/h2&gt;

&lt;p&gt;Here's the classic case — a responsive card grid where cards need internal layout that adapts to available space, not just viewport width — built with nothing but the platform:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;section&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"card-grid"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;article&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"card"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;img&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"thumb.jpg"&lt;/span&gt; &lt;span class="na"&gt;alt=&lt;/span&gt;&lt;span class="s"&gt;""&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"card__body"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;h3&amp;gt;&lt;/span&gt;Card title&lt;span class="nt"&gt;&amp;lt;/h3&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;Supporting copy that might run long or short.&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/article&amp;gt;&lt;/span&gt;
  &lt;span class="c"&gt;&amp;lt;!-- more cards --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/section&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.card-grid&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;grid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;grid-template-columns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;repeat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;auto-fit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;minmax&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;240px&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="n"&gt;fr&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="py"&gt;gap&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1.5rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.card&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;container-type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;inline-size&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;border&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt; &lt;span class="nb"&gt;solid&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--color-border&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;border-radius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.75rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;overflow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;clip&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="err"&gt;img&lt;/span&gt; &lt;span class="err"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;100%&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="py"&gt;aspect-ratio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;16&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="m"&gt;9&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;object-fit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;cover&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nd"&gt;:has&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nt"&gt;img&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;.card__body&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="py"&gt;padding-block-start&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nt"&gt;__body&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1rem&lt;/span&gt; &lt;span class="m"&gt;1.25rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="err"&gt;h3&lt;/span&gt; &lt;span class="err"&gt;{&lt;/span&gt;
      &lt;span class="nl"&gt;font-size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;clamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1.1rem&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;1rem&lt;/span&gt; &lt;span class="err"&gt;+&lt;/span&gt; &lt;span class="m"&gt;0.5vw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;1.35rem&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nl"&gt;margin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0.5rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="err"&gt;}&lt;/span&gt;
&lt;span class="err"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;@container&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;min-width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;360px&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nc"&gt;.card__body&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;grid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="py"&gt;grid-template-columns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="n"&gt;fr&lt;/span&gt; &lt;span class="nb"&gt;auto&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;align-items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No grid framework, no Sass, no JavaScript, and it responds to its own container rather than the viewport. Five years ago this genuinely required either a framework or a pile of custom code to get right; now it's the direct, obvious way to write it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the habit outlasts the reason
&lt;/h2&gt;

&lt;p&gt;If none of this is news to you, that's exactly the point — most of these features have been broadly supported for a year or more, and adoption still lags the capability by a wide margin. That's not a technical gap anymore, it's an inertia gap: framework choice gets made once at a project's start, rarely revisited, and "the team already knows Tailwind" is a perfectly good reason to keep using it on an existing codebase. It's a much weaker reason to reach for it by default on a new one.&lt;/p&gt;

&lt;p&gt;The honest version of this take isn't "rip out your framework." It's: the next time you start a project and install one out of reflex, spend fifteen minutes checking whether the specific problem you're reaching for it to solve is actually still a problem. For a lot of projects in 2026, the answer is genuinely no.&lt;/p&gt;

&lt;p&gt;Am I wrong about this? What's the actual dealbreaker keeping your team on a framework right now — I'd genuinely like to know what I'm missing.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;At ArtClick, we build fast, scalable WordPress websites, company websites and custom web systems that balance design, performance and long-term maintainability.&lt;/strong&gt; Whether you're starting from scratch or improving an existing platform, we'd love to help.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://artclickdev.com/" rel="noopener noreferrer"&gt;https://artclickdev.com/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>css</category>
      <category>moderncss</category>
    </item>
    <item>
      <title>Build a Modern Chat UI with Just CSS and JavaScript</title>
      <dc:creator>Artclick</dc:creator>
      <pubDate>Mon, 03 Aug 2026 10:10:41 +0000</pubDate>
      <link>https://dev.to/_artclick/build-a-modern-chat-ui-with-just-css-and-javascript-34ah</link>
      <guid>https://dev.to/_artclick/build-a-modern-chat-ui-with-just-css-and-javascript-34ah</guid>
      <description>&lt;p&gt;Chat interfaces are everywhere now — support widgets, team tools, AI assistants, in-app messaging. They all share the same underlying patterns, and none of them actually require a framework or a component library to get right. A well-built chat UI is mostly a handful of CSS decisions and a couple of small, deliberate JavaScript behaviors.&lt;/p&gt;

&lt;p&gt;This walks through building one from scratch: the markup, the bubble styling, a typing indicator, smart auto-scrolling, message grouping, dark mode, and the accessibility details that are easy to miss.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually makes a chat UI feel right
&lt;/h2&gt;

&lt;p&gt;Before touching code, it helps to name the specific things that separate a chat UI that feels considered from one that feels like a plain list of &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt;s:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Sent vs. received is instantly visually distinct&lt;/strong&gt; — usually via alignment and color, not color alone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Consecutive messages from the same person are visually grouped&lt;/strong&gt;, not repeated with a full avatar/timestamp every time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;New messages don't fight the user for scroll position.&lt;/strong&gt; If someone has scrolled up to reread something, a new message shouldn't yank them back down.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A typing indicator communicates "still here"&lt;/strong&gt; without being distracting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Screen readers get told about new messages&lt;/strong&gt; without interrupting whatever the user is currently doing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything below is really just these five ideas turned into CSS and JS.&lt;/p&gt;

&lt;h2&gt;
  
  
  The markup
&lt;/h2&gt;

&lt;p&gt;Keep it semantic — a list of messages, each one a list item, grouped inside a labeled region:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;section&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"chat-window"&lt;/span&gt; &lt;span class="na"&gt;aria-label=&lt;/span&gt;&lt;span class="s"&gt;"Conversation"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;ul&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"chat-log"&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"chatLog"&lt;/span&gt; &lt;span class="na"&gt;aria-live=&lt;/span&gt;&lt;span class="s"&gt;"polite"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;li&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"message message--received"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;img&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"avatar"&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"avatar.jpg"&lt;/span&gt; &lt;span class="na"&gt;alt=&lt;/span&gt;&lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"bubble"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;Hey, are we still on for the call at 3?&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;time&lt;/span&gt; &lt;span class="na"&gt;datetime=&lt;/span&gt;&lt;span class="s"&gt;"2026-08-03T14:58"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;2:58 PM&lt;span class="nt"&gt;&amp;lt;/time&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/li&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;li&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"message message--sent"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"bubble"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;Yep, I'll send the doc beforehand.&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;time&lt;/span&gt; &lt;span class="na"&gt;datetime=&lt;/span&gt;&lt;span class="s"&gt;"2026-08-03T14:59"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;2:59 PM&lt;span class="nt"&gt;&amp;lt;/time&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/li&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/ul&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/section&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the &lt;code&gt;aria-live="polite"&lt;/code&gt; on the log itself — that one attribute does most of the accessibility work for announcing new messages, and it's easy to forget entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Styling the bubbles
&lt;/h2&gt;

&lt;p&gt;The core trick is flipping &lt;code&gt;flex-direction&lt;/code&gt; for sent messages and shaving one corner off each bubble to fake a "tail" pointing toward its sender:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.chat-log&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;list-style&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;flex&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;flex-direction&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;column&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;gap&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.75rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;margin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.message&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;flex&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;align-items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;flex-end&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;gap&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.5rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;max-width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;75%&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.message--sent&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;align-self&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;flex-end&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;flex-direction&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;row-reverse&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.bubble&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--bubble-received&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;#eef0f3&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--text-color&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;#1a1a1a&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.6rem&lt;/span&gt; &lt;span class="m"&gt;0.9rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;border-radius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1.1rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.message--sent&lt;/span&gt; &lt;span class="nc"&gt;.bubble&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--bubble-sent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;#4b7bec&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#fff&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;border-bottom-right-radius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.3rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.message--received&lt;/span&gt; &lt;span class="nc"&gt;.bubble&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;border-bottom-left-radius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.3rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.bubble&lt;/span&gt; &lt;span class="nt"&gt;time&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;block&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;margin-top&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.25rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;font-size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.7rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.65&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;max-width: 75%&lt;/code&gt; keeps long messages from stretching edge-to-edge, which is what makes a chat window read as "a conversation" instead of "a document."&lt;/p&gt;

&lt;h2&gt;
  
  
  A typing indicator that doesn't feel janky
&lt;/h2&gt;

&lt;p&gt;Three dots, staggered animation delays, done with pure CSS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.typing&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;flex&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;gap&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;4px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.6rem&lt;/span&gt; &lt;span class="m"&gt;0.9rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.typing&lt;/span&gt; &lt;span class="nt"&gt;span&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;6px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;6px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;border-radius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;50%&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;currentColor&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.4&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;animation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;typing-bounce&lt;/span&gt; &lt;span class="m"&gt;1.2s&lt;/span&gt; &lt;span class="n"&gt;infinite&lt;/span&gt; &lt;span class="n"&gt;ease-in-out&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.typing&lt;/span&gt; &lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="nd"&gt;:nth-child&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="err"&gt;2&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;animation-delay&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.15s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nc"&gt;.typing&lt;/span&gt; &lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="nd"&gt;:nth-child&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="err"&gt;3&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;animation-delay&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.3s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;@keyframes&lt;/span&gt; &lt;span class="n"&gt;typing-bounce&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="err"&gt;0&lt;/span&gt;&lt;span class="o"&gt;%,&lt;/span&gt; &lt;span class="err"&gt;60&lt;/span&gt;&lt;span class="o"&gt;%,&lt;/span&gt; &lt;span class="err"&gt;100&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;translateY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nl"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.4&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="err"&gt;30&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;translateY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;-4px&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nl"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because this animates &lt;code&gt;transform&lt;/code&gt; and &lt;code&gt;opacity&lt;/code&gt; rather than &lt;code&gt;top&lt;/code&gt;/&lt;code&gt;height&lt;/code&gt;, it runs on the compositor thread — smooth even on a busy page, and it won't trigger layout on every frame.&lt;/p&gt;

&lt;h2&gt;
  
  
  Auto-scroll — but only when the user would want it
&lt;/h2&gt;

&lt;p&gt;Don't force-scroll to the bottom on every new message — only do it if the user was already near the bottom:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;chatLog&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;chatLog&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;isNearBottom&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;threshold&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// px&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;chatLog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scrollHeight&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;chatLog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scrollTop&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;chatLog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clientHeight&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;threshold&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;appendMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;shouldStick&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;isNearBottom&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;chatLog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insertAdjacentHTML&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;beforeend&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;shouldStick&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;chatLog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scrollTop&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;chatLog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scrollHeight&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If someone has scrolled up to reread earlier messages, this leaves them exactly where they are instead of yanking them back down when a new message arrives.&lt;/p&gt;

&lt;h2&gt;
  
  
  Grouping consecutive messages
&lt;/h2&gt;

&lt;p&gt;When the same person sends several messages in a row, repeating the avatar and timestamp on every single one adds visual noise. Group them if the sender matches and the gap is small:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;shouldGroup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;previous&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;previous&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sender&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;previous&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sender&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;gapMs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;previous&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;gapMs&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 2 minutes&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.message--grouped&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;margin-top&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;-0.4rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.message--grouped&lt;/span&gt; &lt;span class="nc"&gt;.avatar&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
&lt;span class="nc"&gt;.message--grouped&lt;/span&gt; &lt;span class="nt"&gt;time&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;visibility&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;hidden&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Hiding rather than removing keeps the layout width consistent — the avatar still occupies its column, it just isn't drawn.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dark mode, without duplicating every rule
&lt;/h2&gt;

&lt;p&gt;Define the handful of colors that actually change as custom properties once, then swap them under a &lt;code&gt;data-theme&lt;/code&gt; attribute:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nd"&gt;:root&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;--bg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#ffffff&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--text-color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#1a1a1a&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--bubble-received&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#eef0f3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--bubble-sent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#4b7bec&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nt"&gt;data-theme&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;"dark"&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;--bg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#14171c&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--text-color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#e8eaed&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--bubble-received&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#262b33&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--bubble-sent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#5b8dfc&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Toggle &lt;code&gt;data-theme&lt;/code&gt; on &lt;code&gt;&amp;lt;html&amp;gt;&lt;/code&gt; or &lt;code&gt;&amp;lt;body&amp;gt;&lt;/code&gt; with a few lines of JS, and optionally default it from &lt;code&gt;prefers-color-scheme&lt;/code&gt; on first load so the chat window matches the user's system setting before they've touched anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  The accessibility details that get skipped
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;aria-live="polite"&lt;/code&gt;&lt;/strong&gt; on the message log (already in the markup above) announces new messages without interrupting whatever the screen reader is currently reading.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't rely on color alone&lt;/strong&gt; to distinguish sent from received — alignment already does most of that work here, which is exactly why it matters more than color.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't steal focus.&lt;/strong&gt; A new incoming message shouldn't move keyboard focus away from wherever the user currently is, especially not away from an open text input.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Respect reduced motion&lt;/strong&gt; for the typing indicator:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="k"&gt;@media&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prefers-reduced-motion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nc"&gt;.typing&lt;/span&gt; &lt;span class="nt"&gt;span&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;animation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.6&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Keeping it fast as history grows
&lt;/h2&gt;

&lt;p&gt;A chat log is one of the easiest UIs to accidentally make slow, because it's the one place where the DOM keeps growing indefinitely.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Don't keep unlimited history in the DOM.&lt;/strong&gt; Virtualize (render only messages near the viewport) or paginate older messages behind a "load earlier messages" action.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Batch DOM writes&lt;/strong&gt; when inserting many messages at once — build a &lt;code&gt;DocumentFragment&lt;/code&gt; and insert it in one operation instead of appending message-by-message, which forces a reflow on every single insert.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Debounce scroll listeners&lt;/strong&gt; if you're using scroll position to trigger loading older history — a raw &lt;code&gt;scroll&lt;/code&gt; event fires far more often than you need it to.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;None of this requires a framework, a state management library, or a UI kit. A chat interface that feels genuinely well-made comes down to a small set of deliberate choices: clear sent/received distinction, message grouping, scroll that respects what the user is doing, an accessible live region, and a bit of care around performance as history grows. Get those right and the rest is just visual polish on top.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;At ArtClick, we build fast, scalable WordPress websites, company websites and custom web systems that balance design, performance and long-term maintainability.&lt;/strong&gt; Whether you're starting from scratch or improving an existing platform, we'd love to help.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://artclickdev.com/" rel="noopener noreferrer"&gt;https://artclickdev.com/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>uidesign</category>
      <category>tutorial</category>
      <category>css</category>
      <category>javascript</category>
    </item>
    <item>
      <title>How to Improve Website Performance Without Sacrificing Design</title>
      <dc:creator>Artclick</dc:creator>
      <pubDate>Mon, 03 Aug 2026 06:08:03 +0000</pubDate>
      <link>https://dev.to/_artclick/how-to-improve-website-performance-without-sacrificing-design-3d3m</link>
      <guid>https://dev.to/_artclick/how-to-improve-website-performance-without-sacrificing-design-3d3m</guid>
      <description>&lt;p&gt;There's a myth floating around every design review and dev standup: that you have to choose between a beautiful website and a fast one. Product wants the parallax hero. Design wants the custom typeface and the smooth scroll animations. Engineering wants a green Lighthouse score. Everyone assumes someone has to lose.&lt;/p&gt;

&lt;p&gt;They don't. In almost every case where I've seen "performance vs. design" framed as a trade-off, the real problem wasn't the design intent — it was the implementation. A hero image can be visually stunning &lt;em&gt;and&lt;/em&gt; load in under a second. A scroll animation can feel buttery &lt;em&gt;and&lt;/em&gt; never touch the main thread. The tension usually comes from reaching for the heaviest tool available (a 300KB carousel library, an unoptimized 4K JPEG, five different font weights) instead of the lightest one that gets the same visual result.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — Most performance problems aren't design problems. They're image, font, JavaScript, and third-party-script problems wearing a design costume. Below are 12 concrete places to fix the delivery layer without touching the design itself.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why this matters more in 2026 than it used to
&lt;/h2&gt;

&lt;p&gt;Google evaluates real-world page experience through three Core Web Vitals, measured from actual Chrome users in the field (not just your local Lighthouse run):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;What it measures&lt;/th&gt;
&lt;th&gt;"Good" threshold&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;LCP (Largest Contentful Paint)&lt;/td&gt;
&lt;td&gt;How fast the main content appears&lt;/td&gt;
&lt;td&gt;Under 2.5s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;INP (Interaction to Next Paint)&lt;/td&gt;
&lt;td&gt;How responsive the page feels to clicks/taps&lt;/td&gt;
&lt;td&gt;Under 200ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CLS (Cumulative Layout Shift)&lt;/td&gt;
&lt;td&gt;How visually stable the page is while loading&lt;/td&gt;
&lt;td&gt;Under 0.1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These are scored at the 75th percentile of real visitors over a rolling window, which means a fast dev laptop on fiber tells you almost nothing — you're being judged on the experience of your slowest quarter of visitors, usually on mid-range phones over patchy connections. INP in particular (which replaced First Input Delay back in March 2024) tends to be the hardest one to pass, because it's a direct tax on however much JavaScript you're shipping and executing.&lt;/p&gt;

&lt;p&gt;The reason this matters beyond vanity metrics: slow, janky pages measurably cost conversions, session time, and organic visibility. The fix isn't "make it uglier." It's "make it lighter." Here's where to start.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Serve modern, correctly-sized image formats
&lt;/h2&gt;

&lt;p&gt;Images are usually the largest chunk of a page's weight by far, and also the easiest place to claw back performance without touching the design at all. WebP and AVIF routinely cut file size by 30–60% over JPEG/PNG at visually identical quality. Use a &lt;code&gt;&amp;lt;picture&amp;gt;&lt;/code&gt; element so you degrade gracefully for anything that doesn't support the newer format:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;picture&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;source&lt;/span&gt; &lt;span class="na"&gt;srcset=&lt;/span&gt;&lt;span class="s"&gt;"hero.avif"&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"image/avif"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;source&lt;/span&gt; &lt;span class="na"&gt;srcset=&lt;/span&gt;&lt;span class="s"&gt;"hero.webp"&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"image/webp"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;img&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"hero.jpg"&lt;/span&gt; &lt;span class="na"&gt;alt=&lt;/span&gt;&lt;span class="s"&gt;"Product dashboard preview"&lt;/span&gt; &lt;span class="na"&gt;width=&lt;/span&gt;&lt;span class="s"&gt;"1200"&lt;/span&gt; &lt;span class="na"&gt;height=&lt;/span&gt;&lt;span class="s"&gt;"675"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/picture&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  2. Ship the right image size for each viewport
&lt;/h2&gt;

&lt;p&gt;A 2400px-wide image scaled down with CSS is still a 2400px-wide download. Use &lt;code&gt;srcset&lt;/code&gt; and &lt;code&gt;sizes&lt;/code&gt; so the browser picks the right file for the viewport instead of forcing everyone to download the desktop version:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;img&lt;/span&gt;
  &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"card-800.jpg"&lt;/span&gt;
  &lt;span class="na"&gt;srcset=&lt;/span&gt;&lt;span class="s"&gt;"card-400.jpg 400w, card-800.jpg 800w, card-1200.jpg 1200w"&lt;/span&gt;
  &lt;span class="na"&gt;sizes=&lt;/span&gt;&lt;span class="s"&gt;"(max-width: 600px) 100vw, 50vw"&lt;/span&gt;
  &lt;span class="na"&gt;alt=&lt;/span&gt;&lt;span class="s"&gt;"Feature card"&lt;/span&gt;
  &lt;span class="na"&gt;loading=&lt;/span&gt;&lt;span class="s"&gt;"lazy"&lt;/span&gt;
&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  3. Reserve layout space to kill Cumulative Layout Shift
&lt;/h2&gt;

&lt;p&gt;Every image, video, and embed should have explicit dimensions or an &lt;code&gt;aspect-ratio&lt;/code&gt; so the browser reserves space before the file loads. This alone eliminates most CLS issues, and it costs nothing visually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.card-image&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;100%&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;aspect-ratio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;16&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="m"&gt;9&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;object-fit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;cover&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  4. Lazy-load everything except what's above the fold
&lt;/h2&gt;

&lt;p&gt;Add &lt;code&gt;loading="lazy"&lt;/code&gt; to offscreen images, but do the opposite for your LCP element (usually the hero image) — load it eagerly and consider &lt;code&gt;fetchpriority="high"&lt;/code&gt; so the browser prioritizes it in the request queue.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Use variable fonts and smart font-loading
&lt;/h2&gt;

&lt;p&gt;Distinctive type is one of the fastest ways to make a site feel designed rather than templated — but five weights times two families times italics adds up to a lot of render-blocking font requests. A single variable font file can replace six or eight static weight files, with a smoother range of weights as a bonus. Pair it with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;font-display: swap&lt;/code&gt;&lt;/strong&gt; so text renders in a fallback font immediately instead of staying invisible while the custom font downloads.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Preloading&lt;/strong&gt; your critical font (usually body or headline) in the &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; so it starts downloading before CSS is even parsed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Subsetting&lt;/strong&gt; to the character sets you actually use — most Latin-only sites don't need Cyrillic or CJK glyphs bundled in.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  6. Scale type fluidly instead of stacking breakpoints
&lt;/h2&gt;

&lt;p&gt;Maintaining a dozen breakpoint-specific font sizes means more CSS, more overrides, and more specificity fights. A single &lt;code&gt;clamp()&lt;/code&gt; value scales smoothly across viewport widths instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nd"&gt;:root&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;--fs-headline&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;clamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1.75rem&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;1.1rem&lt;/span&gt; &lt;span class="err"&gt;+&lt;/span&gt; &lt;span class="m"&gt;3vw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;3.25rem&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nt"&gt;h1&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;font-size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--fs-headline&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Mixing a fixed &lt;code&gt;rem&lt;/code&gt; floor, a &lt;code&gt;vw&lt;/code&gt;-based fluid middle, and a fixed &lt;code&gt;rem&lt;/code&gt; ceiling gives predictable scaling at the extremes and organic growth in between — no media query needed for the type scale itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Replace JS libraries with native HTML/CSS where you can
&lt;/h2&gt;

&lt;p&gt;A huge amount of "we need this library" is actually solvable with a browser feature that's shipped for years:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Common JS solution&lt;/th&gt;
&lt;th&gt;Native alternative&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Accordion library&lt;/td&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;details&amp;gt;&amp;lt;summary&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Modal library&lt;/td&gt;
&lt;td&gt;Native &lt;code&gt;&amp;lt;dialog&amp;gt;&lt;/code&gt; element&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Carousel plugin&lt;/td&gt;
&lt;td&gt;CSS &lt;code&gt;scroll-snap-type&lt;/code&gt; + &lt;code&gt;overflow-x: auto&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sticky positioning via scroll listener&lt;/td&gt;
&lt;td&gt;&lt;code&gt;position: sticky&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JS-calculated centering&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;transform: translate(-50%, -50%)&lt;/code&gt; or Grid/Flex centering&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Responsive breakpoint grid libraries&lt;/td&gt;
&lt;td&gt;CSS Grid with &lt;code&gt;auto-fit&lt;/code&gt;/&lt;code&gt;minmax()&lt;/code&gt;, or container queries&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  8. Animate transform and opacity, not layout properties
&lt;/h2&gt;

&lt;p&gt;Animating &lt;code&gt;top&lt;/code&gt;, &lt;code&gt;left&lt;/code&gt;, &lt;code&gt;width&lt;/code&gt;, or &lt;code&gt;height&lt;/code&gt; forces the browser to recalculate layout on every frame. Animating &lt;code&gt;transform&lt;/code&gt; and &lt;code&gt;opacity&lt;/code&gt; instead runs on the compositor thread — smoother motion, zero layout thrashing, and it directly protects your INP score:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="c"&gt;/* Avoid: triggers layout on every frame */&lt;/span&gt;
&lt;span class="nc"&gt;.card&lt;/span&gt;&lt;span class="nd"&gt;:hover&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;top&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;-8px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;/* Prefer: GPU-accelerated, no reflow */&lt;/span&gt;
&lt;span class="nc"&gt;.card&lt;/span&gt;&lt;span class="nd"&gt;:hover&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;translateY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;-8px&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Always pair this with a &lt;code&gt;prefers-reduced-motion&lt;/code&gt; media query — one line, and it matters for both accessibility and perceived performance for users who've opted out of motion.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. Code-split and defer JavaScript, then break up long tasks
&lt;/h2&gt;

&lt;p&gt;Every dependency you ship has to be downloaded, parsed, compiled, and executed before it does anything useful — and on a mid-range phone that CPU cost is often far more expensive than the network cost. This is where most INP problems are born.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Code-split by route or component&lt;/strong&gt; so users only download what the current page needs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Defer or async non-critical scripts&lt;/strong&gt; so they don't block the initial render.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Question every dependency.&lt;/strong&gt; If you're importing a 40KB date-picker for a single form field, a native &lt;code&gt;&amp;lt;input type="date"&amp;gt;&lt;/code&gt; might do 90% of the job for free.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Break up long tasks.&lt;/strong&gt; If a single script keeps the main thread busy for 200ms+, every click during that window feels laggy — chunk the work or yield back to the browser between steps.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  10. Audit and lazy-load third-party scripts
&lt;/h2&gt;

&lt;p&gt;Chat widgets, analytics suites, marketing pixels, and embedded social widgets are frequently the single biggest chunk of unaccounted-for JavaScript on a page — and they're the easiest to forget because a developer didn't write them. Audit them regularly, lazy-load anything that isn't needed immediately (a chat widget doesn't need to load before the user has been on the page for a few seconds), and remove anything nobody's checked the dashboard for in six months.&lt;/p&gt;

&lt;h2&gt;
  
  
  11. Set a performance budget and bring it into design reviews
&lt;/h2&gt;

&lt;p&gt;The most durable fix isn't a single optimization — it's a workflow that keeps performance from silently regressing as a site grows.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Set a performance budget up front&lt;/strong&gt; — a total page-weight target or a JS budget — and treat it the same way you'd treat a design spec.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prototype interactions in CSS first.&lt;/strong&gt; Reach for JavaScript only once you've confirmed CSS genuinely can't do it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build a component library with performance baked in&lt;/strong&gt; (optimized images, correct font-loading, GPU-friendly animations by default) so every new page inherits good defaults.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bring performance into design reviews&lt;/strong&gt;, not just code reviews. A designer who knows a full-bleed video hero has a measurable LCP cost can make an informed call about whether it's worth it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  12. If you're on WordPress, watch plugin and builder weight
&lt;/h2&gt;

&lt;p&gt;WordPress powers a huge share of the web, and it's also where I see this trade-off get treated as unavoidable most often — usually because of accumulated plugin weight rather than WordPress itself.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pick a lean, well-coded theme&lt;/strong&gt; over a heavy all-in-one page builder. Builders that inject their own CSS/JS framework on every page add overhead even where it's unused.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audit plugins ruthlessly.&lt;/strong&gt; Each one is a potential render-blocking stylesheet or script, whether or not it's doing anything on the current page.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use caching and image-optimization plugins&lt;/strong&gt; as a supplement to fixing root causes, not a replacement — a caching layer can't fix a 4MB unoptimized hero image.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prefer native Gutenberg blocks&lt;/strong&gt; for layout where possible over a third-party builder's custom block library, which usually ships more CSS/JS than the native editor.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Measuring: don't guess, check the field data
&lt;/h2&gt;

&lt;p&gt;Lab tools like Lighthouse and PageSpeed Insights are great for quick local iteration, but they're a simulation. The scores that actually count are field data — real Chrome users, captured in the Chrome UX Report (CrUX) and surfaced in Google Search Console. Field data updates on a rolling ~28-day window, so give a fix a few weeks before judging whether it worked. A practical loop:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Check Search Console's Core Web Vitals report to find pages in the "poor" or "needs improvement" band.&lt;/li&gt;
&lt;li&gt;Run PageSpeed Insights on those specific URLs to get a diagnostic breakdown.&lt;/li&gt;
&lt;li&gt;Use Chrome DevTools' Performance panel to find the actual bottleneck — a network waterfall for LCP, long tasks for INP, or the layout shift overlay for CLS.&lt;/li&gt;
&lt;li&gt;Fix the metric furthest from "good" first. Don't spend time optimizing a metric that's already green.&lt;/li&gt;
&lt;li&gt;Re-test, then wait for the CrUX window to confirm it in field data.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Quick reference checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Images served in WebP/AVIF with a JPEG/PNG fallback&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;srcset&lt;/code&gt;/&lt;code&gt;sizes&lt;/code&gt; used so devices don't download oversized images&lt;/li&gt;
&lt;li&gt;Every image/video has explicit dimensions or &lt;code&gt;aspect-ratio&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Offscreen images lazy-loaded; hero/LCP image loaded eagerly&lt;/li&gt;
&lt;li&gt;Variable fonts used where possible, with &lt;code&gt;font-display: swap&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt; Critical font preloaded; unused character sets subsetted out&lt;/li&gt;
&lt;li&gt; Fluid type scales (&lt;code&gt;clamp()&lt;/code&gt;) instead of a dozen breakpoint overrides&lt;/li&gt;
&lt;li&gt; Native HTML/CSS used before reaching for a JS library&lt;/li&gt;
&lt;li&gt; Animations use &lt;code&gt;transform&lt;/code&gt;/&lt;code&gt;opacity&lt;/code&gt;, not layout-triggering properties&lt;/li&gt;
&lt;li&gt; JS code-split, deferred/async, and audited for what's actually used&lt;/li&gt;
&lt;li&gt; Third-party scripts audited and lazy-loaded where possible&lt;/li&gt;
&lt;li&gt; Performance budget agreed with design/product up front&lt;/li&gt;
&lt;li&gt; Field data (CrUX / Search Console) checked, not just lab scores&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The real takeaway
&lt;/h2&gt;

&lt;p&gt;Performance and design aren't opposing forces pulling a project in different directions — they're both just requirements. Once you stop treating speed as an afterthought bolted on at the end of a project, and start treating it as a design constraint from day one (the same way you'd treat accessibility or brand consistency), the "trade-off" mostly disappears. The sites that feel both fast and beautiful aren't the ones that compromised on design. They're the ones that were disciplined about implementation.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;At ArtClick, we build fast, scalable WordPress websites, company websites and custom web systems that balance design, performance and long-term maintainability.&lt;/strong&gt; Whether you're starting from scratch or improving an existing platform, we'd love to help.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://artclickdev.com/" rel="noopener noreferrer"&gt;https://artclickdev.com/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>performance</category>
      <category>webperf</category>
      <category>javascript</category>
    </item>
  </channel>
</rss>
