<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[teodoravadani]]></title><description><![CDATA[teodoravadani]]></description><link>https://teodoravadani.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Sun, 25 Oct 2026 10:27:32 GMT</lastBuildDate><atom:link href="https://teodoravadani.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Make your v0 or Cursor app stop looking AI-built in 10 minutes]]></title><description><![CDATA[Five tells that make an AI-built Next.js landing page look generated, and a Tailwind v4 fix for each.]]></description><link>https://teodoravadani.hashnode.dev/make-your-v0-or-cursor-app-stop-looking-ai-built-in-10-minutes</link><guid isPermaLink="true">https://teodoravadani.hashnode.dev/make-your-v0-or-cursor-app-stop-looking-ai-built-in-10-minutes</guid><category><![CDATA[Next.js]]></category><category><![CDATA[React]]></category><category><![CDATA[Tailwind CSS]]></category><category><![CDATA[Web Development]]></category><dc:creator><![CDATA[Teodor Avadani]]></dc:creator><pubDate>Tue, 20 Oct 2026 23:54:32 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6213aa9ef56c896b3792f43b/4f69bba1-24b9-45f6-9bee-a0089c8867a9.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>If you've built a landing page with v0, Cursor, Lovable or Bolt, you've probably had this moment: you put it next to someone else's and it's the same page. Purple-to-blue gradient, centred headline, one font, three feature cards with an icon in a circle, a pricing table, and a "Get started" button that does nothing you can see.</p>
<p>Nothing on that page is broken. It's the average of every landing page the model has seen, and that's what you get when you don't ask for anything specific. Visitors recognise it within seconds, though, and some of them decide the product behind it got the same amount of thought.</p>
<p>Here are the five tells I look for and a fix for each. Every fix is a couple of minutes in a Next.js App Router project with Tailwind CSS v4. I put the result in a free starter (MIT) so you can copy single pieces or start from the whole thing:</p>
<ul>
<li>Repo: <a href="https://github.com/teodoravadani/qodesign-next-starter">https://github.com/teodoravadani/qodesign-next-starter</a></li>
<li>Live demo: <a href="https://teodoravadani.github.io/qodesign-next-starter/">https://teodoravadani.github.io/qodesign-next-starter/</a></li>
</ul>
<img src="https://raw.githubusercontent.com/teodoravadani/qodesign-next-starter/main/.github/demo.gif" alt="The starter: signup toast, theme toggle, pricing switch" style="display:block;margin:0 auto" />

<h2>The five tells</h2>
<ol>
<li>A gradient where a brand colour should be.</li>
<li>One font at one weight for everything.</li>
<li>Everything centred.</li>
<li>An illustration where the product should be.</li>
<li>Buttons that don't respond.</li>
</ol>
<h2>0. Get a page to work on (1 minute)</h2>
<p>To try the fixes on a clean page:</p>
<pre><code class="language-bash">npx create-next-app@latest my-app -e https://github.com/teodoravadani/qodesign-next-starter
cd my-app
npm run dev
</code></pre>
<p>Or open your own project. The steps assume Next.js 15 (App Router) and Tailwind v4, the setup where <code>globals.css</code> starts with <code>@import "tailwindcss"</code>.</p>
<h2>1. Swap the gradient for three colours (2 minutes)</h2>
<p>Delete the <code>bg-gradient-to-r from-purple-500 to-blue-500</code> (or whatever your hero has). In Tailwind v4 the palette lives in CSS, and every variable in <code>@theme</code> becomes a utility class:</p>
<pre><code class="language-css">/* app/globals.css */
@theme {
  --color-ink: #0a0a0d;
  --color-paper: #f3f1ea;
  --color-volt: #c6ff3d;
}
</code></pre>
<p>Now <code>bg-ink</code>, <code>text-paper</code> and <code>bg-volt</code> exist. Ink is the page, paper is the text (swap them for a light theme), and the accent goes on one or two things per screen: the main button, a status dot. Once the accent is on every element it does the gradient's job again.</p>
<h2>2. Two fonts with some character (1 minute)</h2>
<p>Pick a display face for headings and UI and a mono for numbers and small labels. The starter uses Space Grotesk and JetBrains Mono. The font <code>@import</code> has to be the first line of the file:</p>
<pre><code class="language-css">@import url("https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&amp;family=Space+Grotesk:wght@400;500;600;700&amp;display=swap");
@import "tailwindcss";

@theme {
  --font-sans: "Space Grotesk", ui-sans-serif, system-ui, sans-serif;
  --font-mono: "JetBrains Mono", ui-monospace, monospace;
}
</code></pre>
<p>One trap I hit while testing this: a project set up with <code>shadcn init</code> (v0 projects use shadcn) already has an <code>@theme inline</code> block with <code>--font-sans: var(--font-sans)</code>. That block wins, the variable points at itself, and the page falls back to Times New Roman. Edit the <code>--font-sans</code> and <code>--font-mono</code> lines inside that existing block instead of adding new ones. Also delete any <code>font-family: Arial</code> left on <code>body</code> by the default template, and put <code>font-sans</code> on <code>&lt;body&gt;</code>.</p>
<p>Then use contrast in size and weight: a bold 7xl headline with <code>tracking-tight leading-[1.02]</code>, body text at <code>text-lg</code> and 60% opacity, mono at <code>text-xs</code> for the small stuff.</p>
<h2>3. Move the hero off centre (1 minute)</h2>
<p>Centred layouts are the default because they never look broken. A left-aligned hero with a width limit on the headline looks like someone placed it:</p>
<pre><code class="language-tsx">&lt;section className="mx-auto max-w-6xl px-5 pt-28 pb-16"&gt;
  &lt;h1 className="max-w-3xl text-5xl leading-[1.02] font-bold tracking-tight sm:text-7xl"&gt;
    Know your servers are down before your users do.
  &lt;/h1&gt;
  &lt;p className="mt-6 max-w-xl text-lg text-white/60"&gt;
    Uptime, latency and deploys on one screen. Set up in two minutes, no agent to install.
  &lt;/p&gt;
&lt;/section&gt;
</code></pre>
<p>Write the headline as one sentence about the visitor's problem. "Supercharge your workflow" is the copy version of the purple gradient.</p>
<h2>4. Show the product (2 minutes)</h2>
<p>Generated heroes get a blob or a 3D shape because the model has never seen your app. You have. Screenshot your best screen at 2x and put it in a plain browser frame:</p>
<pre><code class="language-tsx">import Image from 'next/image';
import shot from '@/assets/dashboard.webp';

&lt;div className="mt-16 overflow-hidden rounded-2xl border border-white/10 bg-ink"&gt;
  &lt;div className="flex items-center gap-1.5 border-b border-white/10 px-4 py-3"&gt;
    &lt;span className="size-2.5 rounded-full bg-white/15" /&gt;
    &lt;span className="size-2.5 rounded-full bg-white/15" /&gt;
    &lt;span className="size-2.5 rounded-full bg-white/15" /&gt;
  &lt;/div&gt;
  &lt;Image src={shot} alt="The dashboard" priority sizes="(max-width: 1200px) 100vw, 1152px" /&gt;
&lt;/div&gt;
</code></pre>
<p>The static import gives <code>next/image</code> the width and height, so nothing shifts when it loads, and it keeps working if you deploy under a sub-path. <code>priority</code> is there because it's the biggest thing above the fold.</p>
<p>No app to screenshot yet? The starter has a <code>/dashboard</code> route you can use as a placeholder. Swap in your real screen before you launch.</p>
<h2>5. Make every button respond (3 minutes)</h2>
<p>People feel this one without being able to name it. Click "Start free" on most generated pages and nothing visible happens. A loading state that turns into a confirmation tells the visitor their click did something.</p>
<p>I use the free Toast Stack component for this. It installs from a registry URL with the shadcn CLI, so the source lands in your project and you can edit it:</p>
<pre><code class="language-bash">npx shadcn@latest add https://teodoravadani.github.io/qodesign-free/r/toast-stack.json
</code></pre>
<p>Mount the toaster once in <code>app/layout.tsx</code>:</p>
<pre><code class="language-tsx">import { Toaster } from '@/components/toast-stack/ToastStack';

// inside &lt;body&gt;
{children}
&lt;Toaster /&gt;
</code></pre>
<p>Then wrap your signup call:</p>
<pre><code class="language-tsx">'use client';
import { toast } from '@/components/toast-stack/ToastStack';

const start = () =&gt; {
  toast.promise(createWorkspace(), {
    loading: { title: 'Creating your workspace…' },
    success: (name) =&gt; ({ title: 'Workspace ready', description: `${name} is live. The first check runs in 30 s.` }),
    error: () =&gt; ({ title: 'That didn’t work', description: 'Try again in a minute.' }),
  });
};
</code></pre>
<p>The spinner stays up for at least 600 ms so a fast request doesn't flash, then turns into a check. Hover the stack and the toasts fan out.</p>
<p>Pricing gets the same treatment. The Pricing Switch component replaces a static three-column table with a monthly/yearly switch whose prices roll like an odometer:</p>
<pre><code class="language-bash">npx shadcn@latest add https://teodoravadani.github.io/qodesign-free/r/pricing-switch.json
</code></pre>
<pre><code class="language-tsx">&lt;PricingSwitch
  plans={plans}
  header={&lt;h2&gt;Pay for monitors, not seats.&lt;/h2&gt;}
  onSelect={(plan, yearly) =&gt; startCheckout(plan.id, yearly ? 'year' : 'month')}
/&gt;
</code></pre>
<p>Its digits use Saira Condensed, so add <code>&amp;family=Saira+Condensed:wght@700;800</code> to the font import from step 2.</p>
<h2>Bonus: motion that respects people (30 seconds)</h2>
<p>If you add scroll-in animations, put them behind <code>prefers-reduced-motion</code>. In the starter the sections fade up with a CSS scroll timeline and no JavaScript:</p>
<pre><code class="language-css">@media (prefers-reduced-motion: no-preference) {
  .reveal {
    animation: reveal-up linear both;
    animation-timeline: view();
    animation-range: entry 0% cover 28%;
  }
}
@keyframes reveal-up {
  from { opacity: 0; transform: translateY(28px); }
  to { opacity: 1; transform: none; }
}
</code></pre>
<p>Add <code>className="reveal"</code> to a section. The components in the starter check the same setting on their own.</p>
<h2>If you'd rather hand it to Cursor</h2>
<p>This prompt covers steps 1 to 4. I haven't compared how each tool handles it, so read the diff before you accept it:</p>
<pre><code class="language-text">Restyle the landing page in this Next.js App Router + Tailwind v4 project.
1. Remove every gradient background. In globals.css add --color-ink #0a0a0d, --color-paper #f3f1ea and --color-volt #c6ff3d to @theme. Use volt only on the primary button and one status dot.
2. Fonts: Space Grotesk for headings and UI, JetBrains Mono for numbers and small labels. Add a Google Fonts @import as the first line of globals.css. If an @theme inline block already defines --font-sans or --font-mono, change those lines instead of adding new ones. Put font-sans on &lt;body&gt;.
3. Left-align the hero: one-sentence headline (max-w-3xl, text-5xl sm:text-7xl, tracking-tight), one supporting line (max-w-xl).
4. Replace the hero illustration with next/image of src/assets/screenshot.webp (static import) inside a simple browser frame.
Don't change any other copy. Don't add dependencies.
</code></pre>
<h2>What's in the starter</h2>
<ul>
<li><code>/</code>: hero with a product shot, a log upload section with progress and retry (Upload Dropzone), pricing (Pricing Switch), toasts on every action (Toast Stack), and a light/dark switch (Day/Night Toggle).</li>
<li><code>/dashboard</code>: Orbit, an observability dashboard with simulated live data.</li>
<li>Next.js 15.5, React 19, TypeScript, Tailwind v4, Framer Motion 11. <code>next build</code> passes and every page is static, so it deploys anywhere. The README has a one-click Vercel button.</li>
</ul>
<hr />
<p><strong>About me, and the links.</strong> I'm Teodor, and I make animated UI components at qodesign, so the components in this post are mine. Everything here is free and MIT: the starter and the free components and dashboards in <a href="https://github.com/teodoravadani/qodesign-free">qodesign-free</a>. The rest of the catalog, each with a React + Tailwind version and a PROMPT.md for AI coding tools, is at <a href="https://qodesign.dev/?utm_source=hashnode&amp;utm_medium=tutorial&amp;utm_campaign=next_starter">qodesign.dev</a>.</p>
<p>If you run the five fixes on your own app, I'd like to see the before and after.</p>
<p><em>I wrote this post with help from an AI assistant. The code is copied from the starter repo, and every command above was run in a fresh Next.js 15 app (one plain, one set up with</em> <code>shadcn init</code><em>) with</em> <code>next build</code> <em>passing before publishing.</em></p>
<p><em>Originally published on</em> <a href="https://dev.to/teodoravadani/make-your-v0-or-cursor-app-stop-looking-ai-built-in-10-minutes-46jg"><em>DEV</em></a><em>.</em></p>
]]></content:encoded></item><item><title><![CDATA[A liquid glass tab bar: the magnifying lens is a scaled copy of the bar]]></title><description><![CDATA[Apple's liquid glass tab bars have a lens that sits on the selected tab and magnifies the icon under it. When you switch tabs it moves like a drop of…]]></description><link>https://teodoravadani.hashnode.dev/a-liquid-glass-tab-bar-the-magnifying-lens-is-a-scaled-copy-of-the-bar</link><guid isPermaLink="true">https://teodoravadani.hashnode.dev/a-liquid-glass-tab-bar-the-magnifying-lens-is-a-scaled-copy-of-the-bar</guid><category><![CDATA[CSS]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[React]]></category><category><![CDATA[Web Design]]></category><category><![CDATA[Web Development]]></category><dc:creator><![CDATA[Teodor Avadani]]></dc:creator><pubDate>Tue, 20 Oct 2026 17:57:30 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6213aa9ef56c896b3792f43b/9df61df9-f23f-4807-a633-a7b153491560.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Apple's liquid glass tab bars have a lens that sits on the selected tab and magnifies the icon under it. When you switch tabs it moves like a drop of water: it stretches on the way, then settles.</p>
<p>Most web versions of this effect use an SVG displacement filter inside <code>backdrop-filter</code>. That only renders in Chromium browsers; Safari and Firefox skip it. I built one for a music-app demo and wanted it to look the same in every browser, so the lens uses no filter at all. It's a small window onto a second copy of the bar, scaled up 1.42 times.</p>
<p>This post covers three pieces:</p>
<ol>
<li>The glass bar (plain <code>backdrop-filter</code>).</li>
<li>The lens: a clipped, scaled copy, and the one line of math that keeps it lined up.</li>
<li>The drop: a spring whose speed sets the lens width.</li>
</ol>
<p>There's a single-file demo at the end you can paste into CodePen. If you build with Cursor or Claude Code, it also works as a reference file to hand to your agent: it's short, and every number in it is explained here.</p>
<h2>1. The glass bar</h2>
<p>The bar itself is ordinary frosted glass. Blur plus saturation does most of the work. The inset shadows draw a light top edge and a faint 1px rim so the bar reads as a solid object on any background.</p>
<p>This is the CSS from the shipped component (<code>tabbar-liquid.css</code>):</p>
<pre><code class="language-css">.glassbar {
  position: absolute;
  border-radius: 32px;
  background: var(--glass);
  -webkit-backdrop-filter: blur(16px) saturate(1.9);
  backdrop-filter: blur(16px) saturate(1.9);
  box-shadow:
    inset 0 1px 0 rgba(255,255,255,.22),
    inset 0 0 0 1px var(--glass-line),
    0 18px 40px rgba(0,0,0,.45);
}
</code></pre>
<p><code>saturate(1.9)</code> matters more than it looks. Blur alone washes colours out to grey. Saturation brings back the colour of whatever scrolls underneath, which is what makes it read as glass and not as a grey panel.</p>
<p>Keep the <code>-webkit-</code> prefix. Safari still wants it.</p>
<h2>2. The lens is a copy, not a filter</h2>
<p>Real magnification would need a displacement filter on whatever sits behind the lens. You can fake the part people actually notice, the icon under the lens getting bigger, with plain transforms:</p>
<ul>
<li>Render the tabs a second time inside the lens element.</li>
<li>Give the lens <code>overflow: hidden</code> so you only see the part under it.</li>
<li>Scale the copy by <code>M</code> (1.42 here) around the lens centre.</li>
</ul>
<p>The markup is a lens with an inner layer that holds the copy:</p>
<pre><code class="language-html">&lt;div class="lens" aria-hidden="true"&gt;
  &lt;div class="lensIn"&gt;&lt;!-- the same tabs again, not focusable --&gt;&lt;/div&gt;
&lt;/div&gt;
</code></pre>
<p>The copy must stay out of the accessibility tree and out of the tab order, or a screen reader reads every tab twice. In the React component the copy is rendered with <code>live={false}</code>, which does exactly that:</p>
<pre><code class="language-tsx">const inert = live ? {} : { tabIndex: -1, 'aria-hidden': true as const };
</code></pre>
<h3>The one line of math</h3>
<p><code>.lensIn</code> has <code>transform-origin: 0 0</code> and fills the same box as the real bar. The lens has width <code>w</code>, height <code>h</code>, and centre <code>(x, y)</code> in bar coordinates, so its top-left corner is <code>(lx, ly) = (x - w/2, y - h/2)</code>.</p>
<p>You want the point under the lens centre to stay where it is, with everything around it pushed outwards by <code>M</code>. Here's the render code from the shipped <code>engine.ts</code>:</p>
<pre><code class="language-ts">const Ls = S.lens, lx = Ls.x - Ls.w / 2, ly = Ls.y - Ls.h / 2, M = 1.42;
lens.style.transform = `translate(${lx.toFixed(1)}px,${ly.toFixed(1)}px) scale(${Ls.sx.toFixed(4)},${Ls.sy.toFixed(4)})`;
lensIn.style.transform = `translate(${(-lx + Ls.x - M * Ls.x).toFixed(2)}px,${(-ly + Ls.y - M * Ls.y).toFixed(2)}px) scale(${M})`;
</code></pre>
<p>Why it works: a point <code>p</code> of the copy lands at <code>M·p + t</code> inside the lens, and the lens sits at <code>lx</code>. On screen that is <code>lx + M·p + (-lx + x - M·x)</code>, which simplifies to <code>x + M·(p - x)</code>. At <code>p = x</code> you get <code>x</code>, so the centre stays put and everything else spreads out from it by <code>M</code>. It's the textbook "scale around a point" transform, with the lens offset cancelled out.</p>
<p>Since the copy is real DOM, you can style it apart from the bar. Trimmed from the shipped CSS (the comments are mine):</p>
<pre><code class="language-css">.lens {
  position: absolute; left: 0; top: 0; overflow: hidden; pointer-events: none;
  background: rgba(255,255,255,.07);
  backdrop-filter: blur(3px) saturate(2) brightness(1.18);
  box-shadow:
    inset 0 2px 0 rgba(255,255,255,.75),     /* bright top rim */
    inset 0 -2px 0 rgba(198,244,50,.55),     /* lime bottom rim */
    inset 0 -12px 20px rgba(139,108,255,.4),
    inset 5px 0 9px rgba(255,90,170,.35),    /* pink left edge */
    inset -5px 0 9px rgba(90,220,255,.35),   /* cyan right edge */
    0 0 0 1.5px rgba(255,255,255,.4),
    0 12px 28px rgba(0,0,0,.5),
    0 0 34px rgba(198,244,50,.42);
}
.lensIn { position: absolute; left: 0; top: 0; transform-origin: 0 0; }
.lensIn .tab { color: var(--volt); }        /* the magnified icon turns lime */
.lensIn .tab i { opacity: 0 !important; }   /* hide the label inside the lens */
.lensIn .tab svg { translate: 0 8px; }      /* and drop the icon into the middle */
.lensIn .dockc {
  filter: drop-shadow(1.6px 0 0 rgba(255,70,150,.6))
          drop-shadow(-1.6px 0 0 rgba(70,210,255,.6));
}
</code></pre>
<p>Two details from that block:</p>
<ul>
<li>The pink and cyan inset shadows on the left and right edges, plus two 1.6px <code>drop-shadow</code>s on the copy, fake the colour fringing you see where real glass bends light. They're cheap and they sell the effect more than anything else in the file.</li>
<li>The label is hidden inside the lens and the icon moves down 8px. A magnified label gets cut off by the lens edge and looks like a bug. A centred icon looks deliberate.</li>
</ul>
<p>The cost of this approach is that the content renders twice. For five icons that's nothing. The same component reuses the trick for a bigger orb that lifts out of the bar: the orb's copy holds the whole screen, scaled with the same formula.</p>
<h2>3. The drop: speed sets the width</h2>
<p>A lens that slides at a fixed width looks like a highlight pill. It reads as liquid once it stretches while it moves and goes back to round when it stops.</p>
<p>In the React version the lens x-position is a Framer Motion spring, and <code>useVelocity</code> reads how fast that spring is moving:</p>
<pre><code class="language-tsx">// live mode: the lens rides a spring, and its speed stretches the drop
const x = useSpring(tabX(0), { stiffness: 380, damping: 26 });
const stretch = useVelocity(x);
</code></pre>
<p>Every frame, the speed (px/s) maps to the lens size:</p>
<pre><code class="language-tsx">const v = Math.abs(stretch.get());
S.lens = {
  x: lx, y: g.cy,
  w: mix(66 + Math.min(92, v / 9), 56, c),
  h: mix(52 - Math.min(10, v / 90), 56, c),
  /* ... */
};
</code></pre>
<p>Leave out <code>mix(..., c)</code> for now. That's the compact state, when the bar shrinks on scroll. What's left:</p>
<ul>
<li>At rest the lens is 66 × 52.</li>
<li>It gets 1px wider for every 9 px/s of speed, up to 92px more.</li>
<li>It gets thinner by up to 10px, so a fast move reads as a long, thin drop.</li>
<li>The clamps stop a very fast flick from stretching it across the whole bar.</li>
</ul>
<p>The spring's overshoot does the rest. With stiffness 380 and damping 26 the spring is underdamped (damping ratio about 0.67), so it passes the target a little and comes back. The width follows the speed, so you get a wobble on landing without writing one keyframe.</p>
<p>Switching tabs is one line: <code>x.set(tabX(i))</code>.</p>
<h3>Keyboard and screen readers</h3>
<p>The real tabs are a <code>tablist</code>. Each tab gets <code>role="tab"</code> and <code>aria-selected</code>, and left/right arrows move between them:</p>
<pre><code class="language-tsx">if (e.key !== 'ArrowRight' &amp;&amp; e.key !== 'ArrowLeft') return;
e.preventDefault(); i = (i + (e.key === 'ArrowRight' ? 1 : 4)) % 5;
screen.current?.querySelectorAll&lt;HTMLElement&gt;('[data-dock] [data-tab]')[i]?.focus();
</code></pre>
<p>(<code>+ 4</code> is <code>- 1</code> modulo 5, so left arrow from the first tab wraps to the last.)</p>
<p>The lens, the copy and the rings are all <code>aria-hidden</code>. Only the real tabs are announced.</p>
<h2>The single-file version</h2>
<p>No Framer Motion here. The spring is about ten lines of Hooke's law with damping, using the same stiffness and damping as the React version. Paste it into CodePen's HTML panel or save it as a <code>.html</code> file and open it:</p>
<pre><code class="language-html">&lt;!doctype html&gt;
&lt;html lang="en"&gt;
&lt;head&gt;
&lt;meta charset="utf-8"&gt;
&lt;meta name="viewport" content="width=device-width,initial-scale=1"&gt;
&lt;title&gt;Liquid glass tab bar: magnifying lens demo&lt;/title&gt;
&lt;!-- MIT licence. Teaching version of the lens from qodesign's Liquid Glass Tab Bar (qodesign.dev). --&gt;
&lt;style&gt;
  :root { --ink:#0A0A0D; --text:#F4F4F6; --muted:#8A8FA3; --lime:#C6FF3D; }
  * { box-sizing:border-box; margin:0 }
  body { min-height:100vh; display:grid; place-items:center; background:#050507; font-family:system-ui,sans-serif; color:var(--text) }
  .phone { position:relative; width:390px; height:700px; max-width:100vw; border-radius:44px; overflow:hidden; background:var(--ink) }
  .feed { position:absolute; inset:0; overflow-y:auto; padding:56px 18px 140px; display:grid; gap:14px }
  .card { height:150px; border-radius:22px; display:flex; align-items:flex-end; padding:16px; font-weight:600 }
  .card:nth-child(3n+1) { background:linear-gradient(135deg,#8B6CFF,#2a1d66) }
  .card:nth-child(3n+2) { background:linear-gradient(135deg,#ff6a3d,#5a1d0b) }
  .card:nth-child(3n)   { background:linear-gradient(135deg,#C6FF3D,#2d4a00); color:#0A0A0D }

  /* the glass bar */
  .dock { position:absolute; left:14px; right:14px; bottom:22px; height:76px }
  .bar { position:absolute; inset:0; border-radius:32px; background:rgba(28,30,40,.45);
    -webkit-backdrop-filter:blur(16px) saturate(1.9); backdrop-filter:blur(16px) saturate(1.9);
    box-shadow:inset 0 1px 0 rgba(255,255,255,.22), inset 0 0 0 1px rgba(255,255,255,.1), 0 18px 40px rgba(0,0,0,.45) }
  .tabs { position:absolute; inset:0; display:flex }
  .tab { flex:1; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:3px;
    border:0; background:none; color:var(--muted); font:600 10.5px system-ui; border-radius:24px; cursor:pointer; touch-action:none }
  .tab svg { width:25px; height:25px; fill:none; stroke:currentColor; stroke-width:2; stroke-linecap:round; stroke-linejoin:round }
  .tab[aria-selected="true"] { color:var(--text) }
  .tab:focus-visible { outline:2px solid var(--lime); outline-offset:-4px }

  /* the lens: a window onto a scaled copy of the tabs */
  .lens { position:absolute; left:0; top:0; overflow:hidden; pointer-events:none; will-change:transform,width,height;
    background:rgba(255,255,255,.07);
    -webkit-backdrop-filter:blur(3px) saturate(2) brightness(1.18); backdrop-filter:blur(3px) saturate(2) brightness(1.18);
    box-shadow:inset 0 2px 0 rgba(255,255,255,.75), inset 0 -2px 0 rgba(198,255,61,.55), inset 0 -12px 20px rgba(139,108,255,.4),
      inset 5px 0 9px rgba(255,90,170,.35), inset -5px 0 9px rgba(90,220,255,.35), 0 0 0 1.5px rgba(255,255,255,.4), 0 0 34px rgba(198,255,61,.42) }
  .lens::after { content:""; position:absolute; left:14%; right:14%; top:5%; height:34%; border-radius:50%;
    background:linear-gradient(rgba(255,255,255,.5),rgba(255,255,255,0)) }
  .lensIn { position:absolute; left:0; top:0; transform-origin:0 0;
    filter:drop-shadow(1.6px 0 0 rgba(255,70,150,.6)) drop-shadow(-1.6px 0 0 rgba(70,210,255,.6)) }
  .lensIn .tab { color:var(--lime) }
  .lensIn .tab span { opacity:0 }
  .lensIn .tab svg { translate:0 8px }
&lt;/style&gt;
&lt;/head&gt;
&lt;body&gt;
&lt;div class="phone"&gt;
  &lt;div class="feed" aria-label="Feed"&gt;
    &lt;div class="card"&gt;Daily mix&lt;/div&gt;&lt;div class="card"&gt;Late night&lt;/div&gt;&lt;div class="card"&gt;Focus&lt;/div&gt;
    &lt;div class="card"&gt;Running&lt;/div&gt;&lt;div class="card"&gt;Vinyl finds&lt;/div&gt;&lt;div class="card"&gt;Live sets&lt;/div&gt;
  &lt;/div&gt;
  &lt;nav class="dock"&gt;
    &lt;div class="bar"&gt;&lt;/div&gt;
    &lt;div class="tabs" role="tablist" aria-label="Sections" id="tabs"&gt;&lt;/div&gt;
    &lt;div class="lens" id="lens" aria-hidden="true"&gt;&lt;div class="lensIn" id="lensIn"&gt;&lt;/div&gt;&lt;/div&gt;
  &lt;/nav&gt;
&lt;/div&gt;
&lt;script&gt;
const TABS = [
  ['Home',    '&lt;path d="M3 11l9-7 9 7v9a1 1 0 0 1-1 1h-5v-6H9v6H4a1 1 0 0 1-1-1z"/&gt;'],
  ['Explore', '&lt;circle cx="12" cy="12" r="9"/&gt;&lt;path d="M15.5 8.5l-2 5-5 2 2-5z"/&gt;'],
  ['Library', '&lt;path d="M4 4v16M9 4v16M14 4l6 16"/&gt;'],
  ['Inbox',   '&lt;path d="M4 13l2-8h12l2 8v6H4z"/&gt;&lt;path d="M4 13h5l1 2h4l1-2h5"/&gt;'],
  ['Profile', '&lt;circle cx="12" cy="8" r="4"/&gt;&lt;path d="M4 21c1.5-4 4.5-6 8-6s6.5 2 8 6"/&gt;'],
];
const tabHTML = (live) =&gt; TABS.map(([label, icon], i) =&gt;
  `&lt;button class="tab" type="button" ${live ? `role="tab" aria-selected="${i === 0}" tabindex="${i ? -1 : 0}"` : 'tabindex="-1"'}&gt;
     &lt;svg viewBox="0 0 24 24" aria-hidden="true"&gt;${icon}&lt;/svg&gt;&lt;span&gt;${label}&lt;/span&gt;&lt;/button&gt;`).join('');

const $ = (id) =&gt; document.getElementById(id);
const dock = document.querySelector('.dock'), tabsEl = $('tabs'), lens = $('lens'), lensIn = $('lensIn');
tabsEl.innerHTML = tabHTML(true);
lensIn.innerHTML = `&lt;div class="tabs"&gt;${tabHTML(false)}&lt;/div&gt;`;   // the copy the lens magnifies
const tabs = [...tabsEl.children];
const M = 1.42;                                                     // magnification
const reduce = matchMedia('(prefers-reduced-motion: reduce)');

// a tiny spring: same feel as Framer Motion's useSpring({ stiffness: 380, damping: 26 })
const spring = { x: 0, v: 0, target: 0, k: 380, c: 26 };
const centerOf = (i) =&gt; tabs[i].offsetLeft + tabs[i].offsetWidth / 2;
let active = 0;

function select(i, focus) {
  active = (i + tabs.length) % tabs.length;
  tabs.forEach((t, j) =&gt; { t.setAttribute('aria-selected', j === active); t.tabIndex = j === active ? 0 : -1; });
  if (focus) tabs[active].focus();
  spring.target = centerOf(active);
  if (reduce.matches) { spring.x = spring.target; spring.v = 0; }
}

function paint() {
  const W = dock.clientWidth, H = dock.clientHeight;
  lensIn.style.width = W + 'px'; lensIn.style.height = H + 'px';
  const speed = Math.abs(spring.v);                        // px per second
  const w = 66 + Math.min(92, speed / 9);                  // faster = longer drop
  const h = 52 - Math.min(10, speed / 90);                 // ...and a little thinner
  const x = Math.max(w / 2, Math.min(W - w / 2, spring.x));      // keep the drop inside the bar
  const y = H / 2, lx = x - w / 2, ly = y - h / 2;
  lens.style.width = w + 'px'; lens.style.height = h + 'px';
  lens.style.borderRadius = Math.min(w, h) / 2 + 'px';
  lens.style.transform = `translate(${lx}px, ${ly}px)`;
  // keep the point under the lens centre still, and scale everything around it
  lensIn.style.transform = `translate(${-lx + x - M * x}px, ${-ly + y - M * y}px) scale(${M})`;
}

let last = performance.now();
function frame(now) {
  const dt = Math.min(0.032, (now - last) / 1000); last = now;
  const a = -spring.k * (spring.x - spring.target) - spring.c * spring.v;   // Hooke + damping, mass 1
  spring.v += a * dt; spring.x += spring.v * dt;
  paint();
  requestAnimationFrame(frame);
}

// tap, drag across the bar, or use the arrow keys
const nearest = (clientX) =&gt; {
  const r = dock.getBoundingClientRect(), px = clientX - r.left;
  return tabs.reduce((best, t, i) =&gt; Math.abs(centerOf(i) - px) &lt; Math.abs(centerOf(best) - px) ? i : best, 0);
};
tabsEl.addEventListener('pointerdown', (e) =&gt; { tabsEl.setPointerCapture(e.pointerId); select(nearest(e.clientX)); });
tabsEl.addEventListener('pointermove', (e) =&gt; { if (e.buttons) select(nearest(e.clientX)); });
tabsEl.addEventListener('keydown', (e) =&gt; {
  if (e.key === 'ArrowRight') { e.preventDefault(); select(active + 1, true); }
  if (e.key === 'ArrowLeft')  { e.preventDefault(); select(active - 1, true); }
});
addEventListener('resize', () =&gt; { spring.target = spring.x = centerOf(active); });

spring.x = spring.target = centerOf(0);
requestAnimationFrame(frame);
&lt;/script&gt;
&lt;/body&gt;
&lt;/html&gt;
</code></pre>
<p>The parts worth reading in the demo:</p>
<pre><code class="language-js">// a tiny spring: same feel as Framer Motion's useSpring({ stiffness: 380, damping: 26 })
const a = -spring.k * (spring.x - spring.target) - spring.c * spring.v;
spring.v += a * dt; spring.x += spring.v * dt;

const speed = Math.abs(spring.v);              // px per second
const w = 66 + Math.min(92, speed / 9);        // faster = longer drop
const h = 52 - Math.min(10, speed / 90);       // ...and a little thinner
const x = Math.max(w / 2, Math.min(W - w / 2, spring.x));   // keep the drop inside the bar
lensIn.style.transform = `translate(${-lx + x - M * x}px, ${-ly + y - M * y}px) scale(${M})`;
</code></pre>
<p>The demo adds three things the shipped component doesn't have:</p>
<ul>
<li>Drag along the bar and the lens follows your finger to the nearest tab, stretching over every tab it passes.</li>
<li>It clamps the drop inside the bar, because a 158px drop on the last tab hangs off the edge.</li>
<li>With <code>prefers-reduced-motion: reduce</code> it jumps straight to the new tab, with no stretch and no wobble. Do that in anything you ship. A drop that stretches across the screen is exactly the kind of motion that setting is for.</li>
</ul>
<h2>Easy ways to get it wrong</h2>
<ul>
<li><strong>Scaling the copy around its own centre.</strong> The magnified icon drifts sideways as the lens moves. Use <code>transform-origin: 0 0</code> and put the whole offset in the translate.</li>
<li><strong>A CSS transition on the lens width.</strong> The width then lags behind the position. Set width, height and transform in the same frame, all read from the spring.</li>
<li><strong>A focusable copy.</strong> Keyboard users tab through ten tabs instead of five. Mark the copy <code>aria-hidden</code> and give every button in it <code>tabindex="-1"</code>.</li>
<li><strong>Labels inside the lens.</strong> They get cut by the lens edge. Hide them in the copy.</li>
</ul>
<hr />
<p><strong>About me, and the links.</strong> I'm Teodor, and I make animated UI components at qodesign. The tab bar in this post is one of them, so this is partly a post about my own product. The demo above is MIT: copy it, change it, ship it. The full component adds the orb that turns into a full-screen player, the compact bar on scroll, the badge pop, a React + TypeScript + Tailwind + Framer Motion version, and a <a href="http://PROMPT.md">PROMPT.md</a> for AI coding tools. It's <a href="https://qodesign.dev/components/tabbar-liquid?utm_source=devto&amp;utm_medium=tutorial&amp;utm_campaign=tabbar_lens">on qodesign.dev</a>. A few components are also on GitHub for free (MIT): two dashboards, a toast stack, a pricing switch and an upload dropzone, in <a href="https://github.com/teodoravadani/qodesign-free">qodesign-free</a>.</p>
<p>If you build a lens with a real displacement map that works in Safari, I'd like to see it.</p>
<p><em>I wrote this post with help from an AI assistant. The code excerpts are copied from the shipped component, and the demo was run and checked in a browser before publishing.</em></p>
]]></content:encoded></item></channel></rss>