<?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[Wingo UI Blog]]></title><description><![CDATA[Guides, tutorials and honest comparisons for building React and Tailwind CSS interfaces that work on phones and with AI coding agents. From the team behind Wing]]></description><link>https://wingo-ui.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6ad6a430d0d0b34960b508b1/2588b9e2-eb68-4543-85c5-24908e21d08e.png</url><title>Wingo UI Blog</title><link>https://wingo-ui.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Tue, 27 Oct 2026 09:18:08 GMT</lastBuildDate><atom:link href="https://wingo-ui.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Best React Component Library for Mobile in 2026: 9 Compared]]></title><description><![CDATA[The best React component library for mobile web apps in 2026: 9 libraries checked at 390px for bottom sheets, touch targets, safe areas and gestures.]]></description><link>https://wingo-ui.hashnode.dev/best-react-component-library-for-mobile-in-2026-9-compared</link><guid isPermaLink="true">https://wingo-ui.hashnode.dev/best-react-component-library-for-mobile-in-2026-9-compared</guid><category><![CDATA[React]]></category><category><![CDATA[Mobile Development]]></category><category><![CDATA[Web Development]]></category><dc:creator><![CDATA[Serban Dan Rusu]]></dc:creator><pubDate>Sun, 25 Oct 2026 09:17:20 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ad6a430d0d0b34960b508b1/bfd1f923-b2ca-4793-9ddf-2a5e95f669b9.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2>TL;DR</h2>
<p>For a React web app that people use on phones and desktops, Wingo UI does the most for you: its selects, popovers, menus and dialogs open as draggable bottom sheets below 768px and its buttons grow to 44px on touch. For a mobile-only app or a Capacitor build with a native iOS or Material look, Ionic React is the stronger pick, with Framework7 and antd-mobile as alternatives. MUI, shadcn/ui, HeroUI and Mantine render fine on a 390px screen but leave most phone behavior, from sheets to safe areas, to you.</p>
<p>Picking the best React component library for mobile comes down to what each component does on a phone. This comparison is for React web apps: a Next.js app that people open in a phone browser, add to the home screen or ship inside Capacitor. React Native kits are out of scope. Four things decide the choice: what a select or menu opens on a 390px screen, how big the targets are under a thumb, whether fixed bars clear the notch and the home indicator, and which gestures work. We checked nine libraries on those four points on October 9, 2026.</p>
<p>We build Wingo UI, one of the nine, so read this as a vendor's comparison with sources, including the cases where another library is the better pick. If you arrived from a general "best React UI library 2026" list, this one is narrower: it only scores what happens on a phone. Broader head to head posts live under <a href="https://wingo-ui.com/blog/category/comparisons">Comparisons and alternatives</a>, and our <a href="https://wingo-ui.com/blog/shadcn-alternatives">guide to shadcn alternatives</a> covers prices, licenses and MCP support.</p>
<h2>What is the best React component library for mobile?</h2>
<p>Wingo UI when the same app has to work on phones and desktops, and Ionic React when the app is mobile-only and should look native. The rest of the field fits narrower cases.</p>
<ul>
<li><strong>Phones and desktops from one codebase, Tailwind CSS, source in your repository:</strong> Wingo UI. Its overlays switch to bottom sheets below 768px and its controls grow on touch, so the phone behavior ships inside each component.</li>
<li><strong>A mobile-only app or a Capacitor build with a native look:</strong> Ionic React. It covers every phone pattern in this comparison and picks iOS or Material styling by platform.</li>
<li><strong>The same idea outside Ionic's ecosystem:</strong> Framework7 React, which brings its own app shell and router.</li>
<li><strong>A mobile-only app in the Ant Design family:</strong> antd-mobile.</li>
<li><strong>Tailwind classes with an iOS or Material look, inside an Ionic or Framework7 shell:</strong> Konsta UI.</li>
<li><strong>A desktop-first product that must also work on phones:</strong> MUI gives the most phone help among the general-purpose packages. shadcn/ui, HeroUI and Mantine work too, with more wiring on your side.</li>
</ul>
<h2>How did we test the libraries at 390px?</h2>
<p>We opened each library's own docs demos in Chromium through Playwright at 390 by 844 pixels with touch emulation on, so <code>(pointer: coarse)</code> matched as it does on a phone. On each site we measured the default button and tapped the first Select demo to see what opens and how tall its rows are. Ionic's and Framework7's docs blocked our headless browser, so for those two and for Konsta UI the numbers come from their source code and docs on GitHub. Versions are the latest on npm on October 9, 2026.</p>
<p>Two limits apply. Docs demos use each site's own theme, which matters for shadcn/ui, whose button size depends on the style you pick. And emulation is not an iPhone: it has no home indicator, no on-screen keyboard and no collapsing toolbar, so we checked safe areas in each library's source instead of on screen. Our <a href="https://wingo-ui.com/blog/mobile-first-react-components">mobile-first React components guide</a> lists what only a real phone shows.</p>
<h2>How do the libraries compare on a phone?</h2>
<p>The mobile-only libraries (Ionic, Framework7, antd-mobile) cover every phone pattern, the general-purpose ones leave most of them to you, and Wingo UI ships the phone patterns inside components that also work on a desktop. The table is dated October 2026. "Select at 390px" is what opened when we tapped a select, or what the source does by default.</p>
<table>
<thead>
<tr>
<th>Library, version, license</th>
<th>Select at 390px</th>
<th>Bottom sheet</th>
</tr>
</thead>
<tbody><tr>
<td><a href="https://wingo-ui.com/docs/installation">Wingo UI</a> 1.7.0, 75 of 326 items free</td>
<td>Bottom sheet, 48px rows</td>
<td>Drawer: drag, snap points, nesting</td>
</tr>
<tr>
<td><a href="https://ionicframework.com/docs/api/modal">Ionic React</a> 9.0.7, MIT</td>
<td>Alert dialog by default; action sheet, popover or modal on request</td>
<td>Sheet modal with breakpoints and a handle</td>
</tr>
<tr>
<td><a href="https://github.com/framework7io/framework7/blob/master/src/react/components/sheet.jsx">Framework7 React</a> 9.2.0, MIT</td>
<td>Smart Select opens a page; popup, sheet or popover with <code>openIn</code></td>
<td>Sheet with breakpoints, swipe to close and swipe to step</td>
</tr>
<tr>
<td><a href="https://konstaui.com/react/sheet">Konsta UI</a> 5.5.0, MIT</td>
<td>Native select inside a list input</td>
<td>Sheet without drag (prop and backdrop tap)</td>
</tr>
<tr>
<td><a href="https://github.com/ant-design/ant-design-mobile">antd-mobile</a> 5.43.0, MIT</td>
<td>Picker wheels in a bottom popup</td>
<td>Popup (swipe to close is opt-in), FloatingPanel with px anchors</td>
</tr>
<tr>
<td><a href="https://mui.com/material-ui/react-drawer/">MUI</a> 9.4.0, MIT core</td>
<td>Floating menu, 48px rows</td>
<td>SwipeableDrawer, no snap points</td>
</tr>
<tr>
<td><a href="https://ui.shadcn.com/docs/components/drawer">shadcn/ui</a> CLI, MIT</td>
<td>Floating list, 28px rows</td>
<td>Drawer with swipe and snap points</td>
</tr>
<tr>
<td><a href="https://www.heroui.com/docs/react/components/modal">HeroUI</a> 3.2.6, MIT</td>
<td>Floating popover, 36px rows</td>
<td>Modal sits at the bottom below 640px; Drawer drags from its handle, header or footer</td>
</tr>
<tr>
<td><a href="https://mantine.dev/core/modal/">Mantine</a> 9.7.1, MIT</td>
<td>Floating dropdown, 34px rows</td>
<td>Drawer without drag; full-screen Modal via a media query recipe</td>
</tr>
</tbody></table>
<p>The mobile-only libraries keep behaving like a phone app on a wide screen: Ionic's select still opens an alert dialog, and its modal becomes a 600 by 500 pixel card once the window is 768px wide and 600px tall. The general-purpose libraries look right on a desktop and leave most phone behavior to you. Wingo UI aims at the middle: one component that is a sheet on a phone and a popover or dialog on a desktop.</p>
<h2>Which libraries open selects and menus as bottom sheets?</h2>
<p>Among the general-purpose libraries, only Wingo UI opens a select as a bottom sheet by default. The mobile-only libraries get there by option or by design: Ionic's select takes <code>interface="action-sheet"</code> or <code>interface="modal"</code>, Framework7's Smart Select takes <code>openIn: "sheet"</code>, and antd-mobile's Picker always opens in a bottom popup. shadcn/ui, Mantine and HeroUI opened a floating list anchored under the field, with rows of 28, 34 and 36 pixels. MUI also opened a floating menu, but it was the one general-purpose package with finger-sized rows: its menu items grow to 48px below its <code>sm</code> breakpoint of 600px. Konsta UI uses a native <code>select</code>, which opens the phone's own picker and is a fine answer for short lists.</p>
<p>A floating list puts small rows next to the thumb that opened it. A sheet gives full-width rows at the bottom of the screen, where the thumb already is, and leaves room for a search row. In Wingo UI the <a href="https://wingo-ui.com/components/select">Select</a> does this through its <code>responsive</code> prop, which defaults to <code>true</code>, so the call site has no media query:</p>
<pre><code class="language-tsx">"use client";

import { useState } from "react";
import { Select, type SelectOption } from "@/components/ui/select";

const STATES: SelectOption[] = [
  { value: "CA", label: "California", keywords: ["Los Angeles"] },
  { value: "NY", label: "New York", keywords: ["New York City"] },
  { value: "TX", label: "Texas", keywords: ["Austin"] },
];

export function ShippingState() {
  const [state, setState] = useState("");
  return (
    &lt;Select
      label="State"
      placeholder="Choose the state"
      options={STATES}
      value={state}
      onValueChange={setState}
      name="state"
      autoComplete="address-level1"
    /&gt;
  );
}
</code></pre>
<p>Below 768px the field opens a sheet titled with its label, with 48px rows and the chosen row scrolled into view. With more than 8 options a search row sits under the title, and on a touch screen it waits for a tap instead of raising the keyboard over the list. Pass <code>responsive={false}</code> to keep the floating list on phones. The Popover, Dropdown Menu, Dialog and date pickers follow the same rule, and all but the date pickers are free.</p>
<blockquote>
<p><strong>Live demo: Select.</strong> The original article embeds an interactive demo here (On a phone, tap the State field: a sheet slides up with large rows and a search box; on a wide screen the same field opens a list under it). <a href="https://wingo-ui.com/blog/best-react-component-library-for-mobile">Try it live on wingo-ui.com</a> or open the <a href="https://wingo-ui.com/components/select">Select docs</a>. Install: <code>npx wingo-ui@latest add select</code></p>
</blockquote>
<h2>How big are the touch targets out of the box?</h2>
<p>Most libraries ship a 32 to 40 pixel default button and keep that size on every device. WCAG 2.2 asks for 24 by 24 pixels at level AA and 44 by 44 at level AAA, and Apple's guidelines use 44 points. Every default button here clears AA; few reach 44 without help.</p>
<table>
<thead>
<tr>
<th>Library</th>
<th>Default button</th>
<th>Changes on touch</th>
<th>How we know</th>
</tr>
</thead>
<tbody><tr>
<td>Wingo UI</td>
<td>40px, 44px on touch</td>
<td>Yes, by pointer type</td>
<td>Measured</td>
</tr>
<tr>
<td>Ionic React</td>
<td>About 50px (iOS mode), 36px (Material mode)</td>
<td>By platform mode</td>
<td>Source</td>
</tr>
<tr>
<td>Framework7 React</td>
<td>34px (iOS), 40px (Material)</td>
<td>By platform mode</td>
<td>Source</td>
</tr>
<tr>
<td>Konsta UI</td>
<td>34px (iOS), 40px (Material)</td>
<td>By theme</td>
<td>Source</td>
</tr>
<tr>
<td>antd-mobile</td>
<td>40px</td>
<td>No</td>
<td>Measured</td>
</tr>
<tr>
<td>MUI</td>
<td>36.5px</td>
<td>No</td>
<td>Measured</td>
</tr>
<tr>
<td>shadcn/ui</td>
<td>32px in the docs; 28 to 40px across its eight styles</td>
<td>No</td>
<td>Measured, source</td>
</tr>
<tr>
<td>HeroUI</td>
<td>40px, 36px from 768px</td>
<td>By width</td>
<td>Measured, source</td>
</tr>
<tr>
<td>Mantine</td>
<td>36px</td>
<td>No</td>
<td>Measured</td>
</tr>
</tbody></table>
<p>Wingo UI is the only library here whose buttons change size by pointer type rather than by screen width or platform, so a touch laptop gets 44px buttons and a narrow desktop window keeps 40px. The rule is one Tailwind variant in the size map of the <a href="https://wingo-ui.com/components/button">Button</a>:</p>
<pre><code class="language-tsx">// components/ui/button.tsx (excerpt)
md: "h-10 gap-2 px-4 text-sm pointer-coarse:h-11",
</code></pre>
<p>The small size keeps its 32px look and gets a 44px hit area through a pseudo-element instead. Our <a href="https://wingo-ui.com/blog/minimum-touch-target-size-react-tailwind">touch target guide</a> explains when <code>pointer: coarse</code> beats <code>any-pointer: coarse</code> and how to test it.</p>
<h2>Which libraries handle safe areas for you?</h2>
<p>The mobile-only libraries do, and most general-purpose ones do not. Ionic pads its toolbars, tab bar and modals with <code>--ion-safe-area-*</code> variables you can override, and Framework7 does the same with <code>--f7-safe-area-*</code>. Konsta UI ships <code>pb-safe</code> style utilities that its toolbars use. antd-mobile has a <code>SafeArea</code> component, but its tab bar's <code>safeArea</code> prop is off by default. We searched the published <code>@mui/material</code> 9.4.0 and <code>@heroui/styles</code> 3.2.6 packages and shadcn/ui's base components and styles for <code>safe-area-inset</code> and found nothing; Mantine uses the insets only in its AppShell, whose footer pads the bottom inset.</p>
<p>All of these read <code>env(safe-area-inset-*)</code>, which stays at 0 until the page opts in with <code>viewport-fit=cover</code>. In the Next.js App Router that is one export from the root layout:</p>
<pre><code class="language-tsx">import type { Viewport } from "next";

export const viewport: Viewport = { viewportFit: "cover" };
</code></pre>
<p>In Wingo UI, 38 component files read the insets. The <a href="https://wingo-ui.com/components/bottom-tab-bar">Bottom Tab Bar</a> pads the home indicator, keeps at least 12px under its floating variant, and publishes its height so a floating button or a sticky buy bar can sit above it. Here it is with <a href="https://wingo-ui.com/components/pull-to-refresh">Pull To Refresh</a> on the page behind it, as a client component your root layout can wrap around <code>children</code>:</p>
<pre><code class="language-tsx">"use client";

import type { ReactNode } from "react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { House, Inbox, Plus, UserRound, Users } from "lucide-react";
import { BottomTabBar, type BottomTabBarItem } from "@/components/ui/bottom-tab-bar";
import { PullToRefresh } from "@/components/ui/pull-to-refresh";

const tabs: BottomTabBarItem[] = [
  { label: "Home", href: "/", icon: &lt;House /&gt; },
  { label: "Clients", href: "/clients", icon: &lt;Users /&gt; },
  { label: "Inbox", href: "/inbox", icon: &lt;Inbox /&gt;, badge: 4 },
  { label: "Profile", href: "/profile", icon: &lt;UserRound /&gt; },
];

export function PhoneShell({ children }: { children: ReactNode }) {
  const router = useRouter();
  const pathname = usePathname();
  return (
    &lt;&gt;
      &lt;PullToRefresh onRefresh={() =&gt; router.refresh()} variant="ios"&gt;
        &lt;div className="pb-(--bottom-bar-offset)"&gt;{children}&lt;/div&gt;
      &lt;/PullToRefresh&gt;
      &lt;BottomTabBar
        className="md:hidden"
        items={tabs}
        currentPath={pathname}
        linkComponent={Link}
        action={{ label: "New client", icon: &lt;Plus /&gt;, href: "/clients/new" }}
        hideOnScroll
      /&gt;
    &lt;/&gt;
  );
}
</code></pre>
<p>The tab bar is fixed to the viewport by default and writes its height, safe area included, to <code>--bottom-bar-offset</code> on the root element, which the <code>pb-(--bottom-bar-offset)</code> wrapper reads so the last row of the page stays above the bar. <code>linkComponent</code> and <code>currentPath</code> hand it the Next.js router, so tabs navigate without a full page load and the active pill follows the route. <code>hideOnScroll</code> slides the bar away while you scroll down and brings it back on the way up, with transforms only. The pull calls <code>router.refresh()</code> to refetch the page's server data and spins for at least 500ms (or until a promise you return settles), and a trackpad scroll past the top pulls too, so the gesture has a desktop path. The <a href="https://wingo-ui.com/blog/tailwind-safe-area-insets">safe area guide</a> covers stacking a buy bar or a floating button above the tab bar.</p>
<blockquote>
<p><strong>Live demo: Bottom Tab Bar.</strong> The original article embeds an interactive demo here (Tap the tabs: the pill glides to the active one; scroll the page and the bar slides away, then comes back when you scroll up). <a href="https://wingo-ui.com/blog/best-react-component-library-for-mobile">Try it live on wingo-ui.com</a> or open the <a href="https://wingo-ui.com/components/bottom-tab-bar">Bottom Tab Bar docs</a>. Install: <code>npx wingo-ui@latest add bottom-tab-bar</code></p>
</blockquote>
<h2>Which libraries ship mobile gestures?</h2>
<p>Wingo UI, Ionic, Framework7 and antd-mobile ship all four common phone gestures. The general-purpose libraries ship a swipeable drawer at most, and Konsta UI none.</p>
<table>
<thead>
<tr>
<th>Library</th>
<th>Snap points</th>
<th>Pull to refresh</th>
<th>Swipe rows</th>
<th>Wheel picker</th>
</tr>
</thead>
<tbody><tr>
<td>Wingo UI</td>
<td>Drawer</td>
<td>Pull To Refresh</td>
<td>List</td>
<td>Picker Wheel</td>
</tr>
<tr>
<td>Ionic React</td>
<td>Sheet modal</td>
<td>Refresher</td>
<td>Item Sliding</td>
<td>Picker</td>
</tr>
<tr>
<td>Framework7 React</td>
<td>Sheet</td>
<td>Page <code>ptr</code></td>
<td>Swipeout</td>
<td>Picker</td>
</tr>
<tr>
<td>antd-mobile</td>
<td>FloatingPanel</td>
<td>PullToRefresh</td>
<td>SwipeAction</td>
<td>Picker</td>
</tr>
<tr>
<td>shadcn/ui</td>
<td>Drawer</td>
<td>No</td>
<td>No</td>
<td>No</td>
</tr>
<tr>
<td>MUI</td>
<td>No</td>
<td>No</td>
<td>No</td>
<td>No</td>
</tr>
<tr>
<td>HeroUI, Mantine, Konsta UI</td>
<td>No</td>
<td>No</td>
<td>No</td>
<td>No</td>
</tr>
</tbody></table>
<p>In Wingo UI the Drawer is free; Pull To Refresh, the swipe rows of the <a href="https://wingo-ui.com/components/list">List</a> and the Picker Wheel are Pro.</p>
<p>For sheets, Ionic's API and ours read almost the same. Ionic needs a <code>0</code> breakpoint before a swipe can dismiss the sheet:</p>
<pre><code class="language-tsx">// Ionic React: stops at half and full height, swipe down to close
// import { IonContent, IonModal } from "@ionic/react";
&lt;IonModal trigger="open-filters" initialBreakpoint={0.5} breakpoints={[0, 0.5, 1]}&gt;
  &lt;IonContent className="ion-padding"&gt;Price, condition and categories&lt;/IonContent&gt;
&lt;/IonModal&gt;

// Wingo UI: the same stops; drag down from the first one to close
// import { Button } from "@/components/ui/button";
// import { Drawer } from "@/components/ui/drawer";
&lt;Drawer trigger={&lt;Button&gt;Filters&lt;/Button&gt;} title="Filters" snapPoints={[0.5, 1]}&gt;
  &lt;p className="text-sm text-muted-foreground"&gt;Price, condition and categories&lt;/p&gt;
&lt;/Drawer&gt;
</code></pre>
<p>The difference is what happens at 768px and up: the <a href="https://wingo-ui.com/components/drawer">Drawer</a> stays a capped sheet, or becomes a side panel or a centered dialog through its <code>desktop</code> prop, while Ionic's sheet modal stays a sheet at the bottom of the screen. Our <a href="https://wingo-ui.com/blog/react-bottom-sheet-for-the-web">bottom sheet guide</a> covers snap points, nesting and the keyboard in detail.</p>
<p>The wheel picker is the gesture most web libraries skip. The <a href="https://wingo-ui.com/components/picker-wheel">Picker Wheel</a> spins with native momentum, ticks as rows pass the center on phones that can vibrate, and works from a keyboard with arrows, Page Up and Down and type to jump. As a field it opens the wheels in a bottom sheet with Done and Cancel on phones and in a popover on a desktop:</p>
<pre><code class="language-tsx">"use client";

import { PickerWheelField, pickerRange } from "@/components/ui/picker-wheel";

export function AppointmentTime() {
  return (
    &lt;PickerWheelField
      label="Appointment time"
      name="time"
      defaultValue={["10", "30"]}
      columns={[
        { id: "hour", label: "h", options: pickerRange(8, 19, { pad: 2 }) },
        { id: "minute", label: "min", options: pickerRange(0, 55, { step: 5, pad: 2 }) },
      ]}
    /&gt;
  );
}
</code></pre>
<blockquote>
<p><strong>Live demo: Picker Wheel.</strong> The original article embeds an interactive demo here (Flick a column: it spins with momentum and clicks into place on a row; with a mouse, drag it or scroll it, or Tab to it and use the arrow keys). <a href="https://wingo-ui.com/blog/best-react-component-library-for-mobile">Try it live on wingo-ui.com</a> or open the <a href="https://wingo-ui.com/components/picker-wheel">Picker Wheel docs</a>. Install: <code>npx wingo-ui@latest add picker-wheel</code></p>
</blockquote>
<h2>When are Ionic React or Framework7 the better choice?</h2>
<p>Pick Ionic React when the app is phone-first, you want it to look native on iOS and Android, and you may ship it to the app stores with Capacitor. Its sheet modal, refresher, sliding items, picker and tab bar are mature, and the modes switch by platform with no work from you. The costs are real: its <code>core.css</code> is required, its <a href="https://ionicframework.com/docs/react/add-to-existing">guide for existing React apps</a> warns that its CSS reset may conflict with your global styles, its router package needs React Router 6, and its components look like a phone app on a desktop. Framework7 React makes a similar trade with its own <code>App</code>, <code>View</code> and routes, and has Vue and Svelte versions too.</p>
<p>Wingo UI is the better fit when the same screens have to work as a desktop product, you style with Tailwind CSS, and you want the source in your repository. It is younger with a much smaller community, it is React only, and it has one design with iOS-style options on some parts instead of a platform mode.</p>
<h2>When should you stay on MUI, shadcn/ui, HeroUI or Mantine?</h2>
<p>Stay when most of your users are on desktops and phones are a secondary target. MUI is the strongest of the four on phones: 48px menu rows on small screens, a <code>SwipeableDrawer</code> and a responsive <code>DatePicker</code> from MUI X that opens in a modal on touch devices. shadcn/ui has a large registry ecosystem and a Drawer with snap points, and you write the <a href="https://wingo-ui.com/blog/shadcn-responsive-dialog-on-mobile">responsive dialog recipe</a> once per overlay. If you go this way, budget for three wrappers: overlay to sheet, touch target sizing and safe-area padding on anything fixed. That checklist is what a mobile first React component library saves you, whichever one you choose.</p>
<h2>Where should you start?</h2>
<p>Start with the overlay your users open most on a phone, because a floating list under a thumb is the most visible mobile failure. Install the free Select with <code>npx wingo-ui@latest add select</code> (no account needed), put it in one form, and open that form on your own phone. The Drawer, Popover, Dialog and Dropdown Menu are free as well. The tab bar, pull to refresh, picker wheel and the other React mobile UI components in the <a href="https://wingo-ui.com/components/category/mobile">Mobile category</a> are part of <a href="https://wingo-ui.com/pricing">Wingo UI Pro</a>, at $8 a month, $80 a year or $150 once. Whichever React mobile UI library you pick, test it at 390px with touch on before you commit.</p>
<h2>Components in this post</h2>
<ul>
<li><a href="https://wingo-ui.com/components/select">Select</a> (Free): A custom select with search, groups, icons, thousands of virtualized rows and a hidden native select for forms. Install: <code>npx wingo-ui@latest add select</code></li>
<li><a href="https://wingo-ui.com/components/drawer">Drawer</a> (Free): The library's bottom sheet on vaul: drag to dismiss, snap points, nesting, and a side panel or dialog on desktop. Install: <code>npx wingo-ui@latest add drawer</code></li>
<li><a href="https://wingo-ui.com/components/bottom-tab-bar">Bottom Tab Bar</a> (Pro): The phone's primary navigation: 3 to 5 tabs at the thumb with an animated pill, badges, a raised center action and hide on scroll. Install: <code>npx wingo-ui@latest add bottom-tab-bar</code></li>
<li><a href="https://wingo-ui.com/components/picker-wheel">Picker Wheel</a> (Pro): The iOS wheel picker for the web: columns that spin with native momentum and click into place on a 3D cylinder, inline or as a field. Install: <code>npx wingo-ui@latest add picker-wheel</code></li>
<li><a href="https://wingo-ui.com/components/pull-to-refresh">Pull To Refresh</a> (Pro): Pull a list down past its top to reload it, with rubberband resistance, an arrow, iOS spokes or a Material ring, and a haptic tick. Install: <code>npx wingo-ui@latest add pull-to-refresh</code></li>
</ul>
<h2>FAQ</h2>
<h3>Which React UI library is best for mobile web apps?</h3>
<p>For an app that runs on phones and desktops, pick a library whose components change behavior on a phone, such as Wingo UI, where overlays become bottom sheets below 768px. For a mobile-only app with a native look, pick Ionic React, which ships sheet modals, pull to refresh, sliding list items, pickers and a tab bar that pads the safe area.</p>
<h3>Does shadcn/ui work on mobile?</h3>
<p>It renders well on phones and its Drawer is a real bottom sheet with swipe and snap points. As of October 2026 its Select, Popover and Dropdown Menu stay floating panels at every width, its default button stays 32px on touch screens, and none of its components pad the safe area, so you add those yourself.</p>
<h3>Is MUI good for mobile?</h3>
<p>Better than most general-purpose libraries: its menu rows grow to 48px below 600px, SwipeableDrawer adds swipe gestures and the MUI X date pickers open in a modal on touch devices. Dialogs need a useMediaQuery recipe to go full screen, its drawer has no snap points, and safe areas are up to you.</p>
<h3>Is Ionic React still a good choice in 2026?</h3>
<p>Yes, for mobile-only apps and Capacitor builds. Version 9.0.7 shipped on October 7, 2026 under the MIT license, with iOS and Material modes picked by platform. It brings its own CSS and router integration, so it fits less naturally into a Tailwind CSS app that also has to look like a desktop product.</p>
<h3>Are these libraries for React Native?</h3>
<p>No. Everything here is for React DOM: web apps, PWAs and web views such as Capacitor. A React Native app needs a React Native component library, since these render HTML elements.</p>
<h3>Can I try Wingo UI on a phone for free?</h3>
<p>Yes. The Drawer, Select, Popover, Dialog, Dropdown Menu and Button are free, and they already switch to bottom sheets and 44px targets on phones. The Bottom Tab Bar, Pull To Refresh, Picker Wheel and the other components of the Mobile category are part of Wingo UI Pro.</p>
<hr />
<p><em>This article was originally published on the Wingo UI blog: <a href="https://wingo-ui.com/blog/best-react-component-library-for-mobile">Best React Component Library for Mobile in 2026: 9 Compared</a>. The original has the live, interactive demos and is kept up to date.</em></p>
]]></content:encoded></item><item><title><![CDATA[Component Library MCP: Make Agents Use Real Components]]></title><description><![CDATA[How an MCP server, rules files, llms.txt and a registry get Claude Code, Cursor and Codex to install real components instead of guessing.]]></description><link>https://wingo-ui.hashnode.dev/component-library-mcp-make-agents-use-real-components</link><guid isPermaLink="true">https://wingo-ui.hashnode.dev/component-library-mcp-make-agents-use-real-components</guid><category><![CDATA[mcp]]></category><category><![CDATA[React]]></category><category><![CDATA[AI]]></category><dc:creator><![CDATA[Serban Dan Rusu]]></dc:creator><pubDate>Sun, 25 Oct 2026 09:15:36 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ad6a430d0d0b34960b508b1/66c5464c-9496-4eb3-a576-d55639c2b690.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2>TL;DR</h2>
<p>A component library MCP server gives coding agents tools to search the catalog, read real props and design rules, and check installed versions, so they stop guessing APIs from training data. It works best with three companions: a short rules file that tells the agent when to call those tools, llms.txt and Markdown docs for agents without MCP, and a registry plus CLI that writes the real files and records their versions.</p>
<p>Ask Claude Code for a settings page and it will write one in a minute. It may also invent a <code>&lt;Button intent="primary"&gt;</code> your library never had, hardcode <code>#3b82f6</code> where a token belongs and rebuild a date picker you already own. A component library MCP server fixes that at the source: it gives the agent tools to look up what exists, read the real props and install the real files. This guide covers how a React component library exposes itself to Claude Code, Cursor and Codex, the four pieces that make it work (an MCP server, a rules file, llms.txt and a registry), and the setup our CLI writes for you.</p>
<p>We build Wingo UI, so the examples use our server, CLI and registry, with output copied from release 1.7.0. The approach applies to any library, an internal design system included, and where another tool fits better, we say so. Shorter guides on agents and UI live under <a href="https://wingo-ui.com/blog/category/ai-agents">Building UI with AI agents</a>: <a href="https://wingo-ui.com/blog/agents-md-vs-claude-md-react">AGENTS.md vs CLAUDE.md vs Cursor rules</a> for the rules file, a <a href="https://wingo-ui.com/blog/claude-code-shadcn-mcp-setup">Claude Code shadcn setup</a> for the shadcn MCP server next to a second registry, and a <a href="https://wingo-ui.com/blog/react-ai-chat-ui">React AI chat UI guide</a> for when the screen you are building is the chat itself.</p>
<h2>What is a component library MCP server?</h2>
<p>A component library MCP server is a <a href="https://modelcontextprotocol.io/">Model Context Protocol</a> server that turns a UI library into tools an agent can call: search the catalog, read one component's props and usage, fetch the design rules, and compare installed versions with the latest release. The agent asks instead of remembering.</p>
<p>The client (Claude Code, Cursor, Codex, Gemini CLI) lists a server's tools and lets the model decide when to call them. A server runs as a local process over stdio or as a remote endpoint over Streamable HTTP. Remote servers are easier to keep current, because every client sees a new release the moment you deploy it.</p>
<p>The React component library MCP servers we checked come in three shapes. Here is how they compare, as of October 2026:</p>
<table>
<thead>
<tr>
<th>Server</th>
<th>Runs as</th>
<th>What the agent gets</th>
<th>Writes files into your project</th>
</tr>
</thead>
<tbody><tr>
<td>shadcn MCP</td>
<td><code>npx shadcn@latest mcp</code> (local)</td>
<td>Browse, search and install items from every registry in <code>components.json</code>, private ones included</td>
<td>Yes, through the shadcn CLI</td>
</tr>
<tr>
<td>MUI MCP</td>
<td><code>npx -y @mui/mcp@latest</code> (local, stdio)</td>
<td>Official Material UI docs and code examples, with links to real pages</td>
<td>No, Material UI is an npm package</td>
</tr>
<tr>
<td>React Suite MCP</td>
<td><code>npx -y @rsuite/mcp@latest</code> (local)</td>
<td><code>get_component_props</code>, <code>list_components</code>, <code>list_hooks</code>, <code>search_components</code></td>
<td>No, React Suite is an npm package</td>
</tr>
<tr>
<td>Wingo UI MCP</td>
<td><code>https://wingo-ui.com/mcp</code> (remote, Streamable HTTP) or <code>npx -y wingo-ui@latest mcp</code> (local)</td>
<td>Search, props with defaults, usage, blocks, design rules, theme tokens, update checks, changelog</td>
<td>Yes, through the Wingo UI CLI</td>
</tr>
</tbody></table>
<p>Sources, checked in October 2026: the <a href="https://ui.shadcn.com/docs/mcp">shadcn MCP docs</a>, the <a href="https://mui.com/material-ui/getting-started/mcp/">Material UI MCP guide</a>, the <a href="https://rsuitejs.com/guide/mcp-server/">React Suite MCP guide</a> and our <a href="https://wingo-ui.com/docs/mcp">MCP server docs</a>.</p>
<p>The first shape is a docs lookup. MUI and React Suite ship npm packages, so the agent needs correct props and working links; it never needs source files. If your app runs on Material UI, MUI's own server is the right choice.</p>
<p>The second shape is a registry installer. The shadcn docs describe listing, searching and installing from any shadcn-compatible registry in <code>components.json</code>; they do not mention design rules or update checks. If you pull from several registries, one shadcn server covers them all, which is a real advantage.</p>
<p>The third shape is what a source-copy library needs: docs, install and maintenance in one server. When components live in your repository as code you can edit, the agent has to know what the library offers, which version your project has and how the library expects to be used. That last part is the one a props table alone does not carry.</p>
<h2>Why do coding agents invent components?</h2>
<p>Because the model writes from training data, and training data predates your library version and leans toward the most common APIs. Without a way to check, the agent fills every gap with the likeliest guess: shadcn's API on a library that is not shadcn, a prop that was renamed two majors ago, a hex color where a semantic token belongs.</p>
<p>The monday.com engineering team described the same failure in <a href="https://engineering.monday.com/how-we-use-ai-to-turn-figma-designs-into-production-code/">How We Use AI to Turn Figma Designs into Production Code</a> (Rivka Ungar, February 2, 2026). Their first attempt, a Figma link pasted into Cursor with the Figma MCP, produced code that hardcoded colors and skipped their design system components. Their diagnosis: "the model had no understanding of what the design system actually was." They then built an MCP server for the design system and found it was still not enough on its own: "the MCP only provided knowledge; it didn't decide how or when that knowledge should be applied across a full UI."</p>
<p>A server full of correct facts does nothing if the agent never calls it, calls it after writing the code, or reads the props and then restyles everything with utility classes. So the server is one of four pieces.</p>
<h2>What does an agent need besides the MCP server?</h2>
<p>It needs a rules file that says when to call which tool, docs it can read without MCP, and a registry plus CLI that writes the files. Each piece answers a different question and changes at a different speed:</p>
<table>
<thead>
<tr>
<th>Piece</th>
<th>What it answers</th>
<th>How often it changes</th>
<th>In Wingo UI</th>
</tr>
</thead>
<tbody><tr>
<td>MCP server</td>
<td>What exists, its props and usage, the design rules, which versions are current</td>
<td>Every release</td>
<td><code>https://wingo-ui.com/mcp</code>, 10 tools and 1 prompt</td>
</tr>
<tr>
<td>Rules file</td>
<td>When to call which tool, how to install, what never to do</td>
<td>Rarely</td>
<td>A marked block in <code>CLAUDE.md</code>, <code>AGENTS.md</code> or <code>GEMINI.md</code>, a Cursor rule, a Claude Code skill</td>
</tr>
<tr>
<td>llms.txt and Markdown docs</td>
<td>The same facts as plain text for agents without MCP</td>
<td>Every release</td>
<td><code>/llms.txt</code>, a file per category, a <code>.md</code> twin of every docs page</td>
</tr>
<tr>
<td>Registry and CLI</td>
<td>The actual files, their dependencies and a version record</td>
<td>Every release</td>
<td><code>/r/&lt;slug&gt;.json</code> in the shadcn format, <code>npx wingo-ui@latest add</code></td>
</tr>
</tbody></table>
<p>The split that matters: the rules file stays short and stable, and everything that changes lives behind the server. Prop tables pasted into a rules file go stale with the next release, and an agent tends to trust the copy already in its context over a tool it would have to call. Our rules file names tools and commands. It never lists a component's props.</p>
<h2>Which tools should a component library MCP server expose?</h2>
<p>A few read-only tools, shaped around what an agent does when it builds UI: find, read, follow the rules, install, stay current. Ours has ten:</p>
<ul>
<li><code>search_components</code>: find components, blocks, hooks and helpers by keywords.</li>
<li><code>list_categories</code>: browse the categories with item counts.</li>
<li><code>get_component</code>: props, parts, usage, dependencies, the install command and the source.</li>
<li><code>get_block</code>: a full screen or section and the components it composes.</li>
<li><code>get_design_rules</code>: tokens, sizes, motion, forms, overlays and mobile rules.</li>
<li><code>get_theme_tokens</code>: the CSS variables for light and dark mode.</li>
<li><code>get_install_instructions</code>: project setup for Next.js or Vite.</li>
<li><code>check_updates</code>: compare installed items with the latest release.</li>
<li><code>get_changelog</code>: recent releases and their notes.</li>
<li><code>get_account_status</code>: whether the key has Pro, with the links to upgrade.</li>
</ul>
<p>There is also one prompt, <code>build_screen</code>, that walks the agent through search, rules, install and composition. Every tool is marked read-only and idempotent in its annotations, and none of them writes to your disk. Writing files is the CLI's job.</p>
<h3>Search should return slugs and one line each</h3>
<p>A search result is a short list the agent can scan in one glance. This is the real answer from our server to <code>search_components</code> with the query "orders table with filters" and <code>limit: 5</code>, trimmed to the first three hits:</p>
<pre><code class="language-text">5 results for "orders table with filters":

- **Data Table** `data-table` (component, pro, v1.1.0): The admin table on TanStack Table v9: search, filters, sorting, bulk actions, pinning, resizing, virtualization and cards on phones.
- **Audit Log** `audit-log` (component, pro, v1.0.0): Who did what, when and to which record: a filterable activity log with day groups, before / after diffs and a CSV export.
- **Rule Builder** `rule-builder` (component, pro, v1.0.0): An if-this-then-that editor for automations: a trigger, conditions and ordered actions as sentence cards, read back as one sentence.

Next: get_component (or get_block for blocks) with a slug for props, usage and the install command.
</code></pre>
<p>Every hit carries its slug, kind, access tier and version, and the last line names the next tool, so the agent never has to guess which call turns a slug into props. The third hit is a weak match. That is fine: one line of description lets the agent skip it without spending a <code>get_component</code> call to find out.</p>
<h3>Props need their defaults</h3>
<p><code>get_component</code> returns the props table the docs page shows, with types and defaults, plus parts, a usage example, the npm and registry dependencies, and the install command. Defaults are what keep generated code short. An agent that knows the <a href="https://wingo-ui.com/components/button">Button</a> defaults to <code>type="button"</code>, <code>tone="neutral"</code> and <code>size="md"</code> writes <code>&lt;Button&gt;Cancel&lt;/Button&gt;</code> instead of spelling out props it guessed. One that knows <code>loading</code> keeps the width and sets <code>aria-busy</code> does not wrap the button in its own spinner logic.</p>
<p>Below is that Button with the brand tone and a loading label set through props.</p>
<blockquote>
<p><strong>Live demo: Button.</strong> The original article embeds an interactive demo here (Click Pay now: the spinner takes the label's place, the width holds and the button ignores clicks until it settles). <a href="https://wingo-ui.com/blog/component-library-mcp-guide">Try it live on wingo-ui.com</a> or open the <a href="https://wingo-ui.com/components/button">Button docs</a>. Install: <code>npx wingo-ui@latest add button</code></p>
</blockquote>
<p>Without JavaScript, the demo is one solid "Pay now" button in the brand color; pressing it shows a spinner and "Paying" in the same width.</p>
<h3>Keep tool output small</h3>
<p>Large answers run into hard limits in the clients. As of October 2026, <a href="https://code.claude.com/docs/en/mcp">Claude Code's MCP docs</a> say it warns when a tool result passes 10,000 tokens, caps results at 25,000 tokens by default (<code>MAX_MCP_OUTPUT_TOKENS</code> raises it) and saves text results over 50,000 characters to a file instead of putting them in context.</p>
<p>Our <a href="https://wingo-ui.com/components/data-table">Data Table</a> is one file of 3,940 lines, about 150 KB of TypeScript. Its docs without source come back as about 18,000 characters, roughly 4,500 tokens. With the source attached (a Pro key), the same call grows past 160,000 characters and passes every one of those limits. So the rule for agents, and for anyone designing one of these servers, is: docs through MCP, files through the CLI. Call <code>get_component</code> with <code>includeSource: false</code> for large items and let <code>npx wingo-ui@latest add</code> write the code.</p>
<p>The same goes for rules. <code>get_design_rules</code> with the topic <code>all</code> returns about 45,000 characters, roughly 11,000 tokens at the usual four characters per token, which is already past Claude Code's warning line. The <code>mobile</code> topic alone is about 1,900 characters and <code>forms</code> about 4,200. Ask for the topic the task needs.</p>
<h3>Errors should say what to do next</h3>
<p>A tool error is a prompt the agent reads, so write it like one. An unknown slug answers with the closest matches and points back to <code>search_components</code>. A Pro item requested without a Pro key answers with the docs and this, word for word from our server (links trimmed, lines wrapped):</p>
<pre><code class="language-text">Login Page is part of Wingo UI Pro; the source is not included for this caller
(no API key; a free account at https://wingo-ui.com/sign-in unlocks the free items).
Do not re-create it from the docs.
Tell the user it needs Pro (https://wingo-ui.com/pricing); with a Pro key
(Authorization: Bearer wui_...) this tool returns the files and
`npx wingo-ui@latest add login-page` works after `npx wingo-ui@latest login`.
Free alternatives to look at: otp-input.
</code></pre>
<p>"Do not re-create it from the docs" is the most important sentence on the server. An agent that has just read a complete props table and a long interaction description has everything it needs to attempt a rewrite, and the result is a half copy that matches the docs on paper and nothing else: no version record, no updates, none of the edge cases the description summarizes in one line.</p>
<h3>Server instructions are the first thing the agent reads</h3>
<p>Claude Code turns on tool search by default, so only tool names and each server's instructions load at the start of a session; full tool definitions load when the model looks for them. The same docs say Claude Code cuts each tool description and each server's instructions at 2,048 characters by default. The instructions are your server's pitch to the model: what tasks it handles, when to search for its tools, and the rules that must survive truncation. Put "search before you write UI" in the first lines.</p>
<p>We learned the cost of that limit on our own server. Its instructions run 2,235 characters in release 1.7.0, so Claude Code drops the last sentence, the one that says never to re-create a component without the right key. The rule still reaches the agent because every refusal and the rules file repeat it. Count your characters, and repeat any rule that matters somewhere the model cannot miss.</p>
<h2>How do I connect Claude Code, Cursor and Codex?</h2>
<p>One CLI command writes both the MCP config and the rules for your agent. With <code>WINGO_UI_API_KEY</code> set it writes the HTTP configs below, which read the key from the environment so it never lands in your repository; without the variable it writes the local stdio proxy. You can also add the server by hand.</p>
<pre><code class="language-bash">npx wingo-ui@latest login                     # once per computer
npx wingo-ui@latest agents install claude     # or codex, gemini, grok, cursor, all
</code></pre>
<p>Running it again changes nothing, and it keeps the other servers in your MCP configs. Where each agent's files go:</p>
<table>
<thead>
<tr>
<th>Agent</th>
<th>MCP config</th>
<th>Rules</th>
</tr>
</thead>
<tbody><tr>
<td>Claude Code</td>
<td><code>.mcp.json</code></td>
<td><code>.claude/skills/wingo-ui/SKILL.md</code> and a marked block in <code>CLAUDE.md</code></td>
</tr>
<tr>
<td>Codex</td>
<td><code>~/.codex/config.toml</code></td>
<td>A marked block in <code>AGENTS.md</code></td>
</tr>
<tr>
<td>Gemini CLI</td>
<td><code>.gemini/settings.json</code></td>
<td>A marked block in <code>GEMINI.md</code></td>
</tr>
<tr>
<td>Grok</td>
<td><code>.mcp.json</code></td>
<td>A marked block in <code>AGENTS.md</code></td>
</tr>
<tr>
<td>Cursor</td>
<td><code>.cursor/mcp.json</code></td>
<td><code>.cursor/rules/wingo-ui.mdc</code></td>
</tr>
</tbody></table>
<p>Access works in three tiers. Without a key, search, docs, props, design rules and tokens all answer, with no source. A key from a free account (email only, no card) adds the source of the 75 free items. A Pro key adds the source of all 326. Create the key in your account and export it in your shell profile as <code>WINGO_UI_API_KEY</code>.</p>
<h3>Claude Code</h3>
<p>Claude Code stores MCP servers in three scopes: local (the default, private to you in <code>~/.claude.json</code>), project (<code>.mcp.json</code> at the repository root, committed for the whole team) and user (all your projects). For a library the team shares, use project scope; <code>.mcp.json</code> expands <code>${VAR}</code> from the environment, so the file is safe to commit:</p>
<pre><code class="language-json">{
  "mcpServers": {
    "wingo-ui": {
      "type": "http",
      "url": "https://wingo-ui.com/mcp",
      "headers": {
        "Authorization": "Bearer ${WINGO_UI_API_KEY}"
      }
    }
  }
}
</code></pre>
<p>For a quick personal setup, the one-liner adds it in local scope:</p>
<pre><code class="language-bash">claude mcp add --transport http wingo-ui https://wingo-ui.com/mcp --header "Authorization: Bearer $WINGO_UI_API_KEY"
</code></pre>
<p>Run <code>/mcp</code> inside a session to see whether the server connected. If you already run other Claude Code MCP servers, this one sits next to them in the same file, and <code>agents install</code> keeps the entries it did not write. Our <a href="https://wingo-ui.com/blog/claude-code-shadcn-mcp-setup">Claude Code shadcn tutorial</a> walks through the shadcn MCP server, its skill and a second registry in the same project.</p>
<h3>Cursor</h3>
<p>Cursor reads <code>.cursor/mcp.json</code> in the project and <code>~/.cursor/mcp.json</code> for every project. It uses its own variable syntax, <code>${env:NAME}</code>, documented in <a href="https://cursor.com/docs/context/mcp">Cursor's MCP docs</a>:</p>
<pre><code class="language-json">{
  "mcpServers": {
    "wingo-ui": {
      "url": "https://wingo-ui.com/mcp",
      "headers": {
        "Authorization": "Bearer ${env:WINGO_UI_API_KEY}"
      }
    }
  }
}
</code></pre>
<h3>Codex</h3>
<p>Codex reads MCP servers from <code>~/.codex/config.toml</code> (or a trusted project's <code>.codex/config.toml</code>). For a remote server, <code>bearer_token_env_var</code> names the variable that holds the token, as the <a href="https://developers.openai.com/codex/mcp">Codex MCP docs</a> describe. Restart Codex after adding it so it picks up the server.</p>
<pre><code class="language-toml">[mcp_servers.wingo-ui]
url = "https://wingo-ui.com/mcp"
bearer_token_env_var = "WINGO_UI_API_KEY"
</code></pre>
<h3>Any other client</h3>
<p>Clients that cannot send a header to a remote server can run the local proxy. It reads the key saved by <code>wingo-ui login</code> and mirrors the remote tools, so new tools appear without updating anything:</p>
<pre><code class="language-json">{
  "mcpServers": {
    "wingo-ui": { "command": "npx", "args": ["-y", "wingo-ui@latest", "mcp"] }
  }
}
</code></pre>
<h2>What goes in the rules file?</h2>
<p>When to call which tool, how to install, and what never to do, in as few lines as you can manage. This is the whole block we put in <code>AGENTS.md</code>, <code>CLAUDE.md</code> or <code>GEMINI.md</code>:</p>
<pre><code class="language-md">&lt;!-- wingo-ui:start v=2 --&gt;
## Wingo UI

This project builds its UI with Wingo UI (React 19 + Tailwind CSS v4 components installed as source, see `wingo-ui.json`).

- Before writing UI, use the `wingo-ui` MCP tools: `search_components` to find what exists (blocks for whole screens), `get_component` / `get_block` for props and usage, `get_design_rules` for tokens, sizes, motion and mobile rules. Never recreate a component the library has.
- Install with `npx wingo-ui@latest add &lt;slug...&gt;`. At the start of UI work run `check_updates` (or `npx wingo-ui@latest outdated`) and mention updates; update with `npx wingo-ui@latest update`.
- Semantic tokens only (`bg-background`, `bg-surface`, `text-muted-foreground`, `border-border`), configure components through props, keep `cn()`, `data-slot` and license headers in copied files. Check 390px and 1440px, light and dark.
- No MCP tools available? The CLI has `search`, `info`, `add`, `outdated`, `update`; the docs for models are at https://wingo-ui.com/llms.txt.
&lt;!-- wingo-ui:end --&gt;
</code></pre>
<p>A few choices in there are deliberate. Every bullet tells the agent what to do or when to do it. It names tools and commands but lists no component's props, because props change and tool names do not. It has a fallback for sessions without the MCP server, so the agent degrades to the CLI instead of guessing. And it carries a version marker: <code>npx wingo-ui@latest agents status</code> tells you whether your copy is current, and <code>agents update</code> rewrites only the text between the markers.</p>
<p>Claude Code also gets a skill with the longer workflow, and Cursor gets a project rule scoped to <code>.tsx</code>, <code>.jsx</code> and <code>.css</code> files. The full setup is on the <a href="https://wingo-ui.com/docs/agents">coding agents docs page</a>.</p>
<p>For an internal library, copy the structure: one line on what the library is, one on which tools to call before writing UI, one on how to install, one on styling, one fallback. Five lines that an agent follows beat fifty that it skims. Which of these files each agent reads, and how one AGENTS.md can serve Claude Code, Codex and Cursor at once, is in our <a href="https://wingo-ui.com/blog/agents-md-vs-claude-md-react">AGENTS.md vs CLAUDE.md comparison</a>.</p>
<h2>Where do llms.txt and Markdown docs fit?</h2>
<p>They serve assistants that can read a URL but have no MCP connection, and they back up the server when it is not configured. The <a href="https://llmstxt.org/">llms.txt proposal</a>, published by Jeremy Howard in September 2024, puts a Markdown file at the site root: an H1 with the name, a short summary, then sections of links. It also recommends a clean Markdown version of each page at the same URL with <code>.md</code> added, and its current version (updated August 2026) describes a <code>rel="alternate"</code> link of type <code>text/markdown</code> that points to it.</p>
<p>Our version follows that layout:</p>
<ul>
<li><a href="https://wingo-ui.com/llms.txt">/llms.txt</a> is the map: what the library is, the counts, pricing, the docs pages, the install commands, a link per category and one line per item with its Markdown link.</li>
<li><code>/llms/&lt;category&gt;.txt</code> holds the item docs for one category, for example every table or every overlay.</li>
<li>Every docs page has a Markdown twin, like <a href="https://wingo-ui.com/components/button.md">/components/button.md</a>, linked from the HTML head.</li>
<li><a href="https://wingo-ui.com/llms-full.txt">/llms-full.txt</a> has everything in one file, about 3.3 MB as of October 2026 (the docs of release 1.7.0 plus the blog posts).</li>
</ul>
<p>That last file is the trap. A model handed 3.3 MB of docs either truncates it or spends its context on components it will never use. Treat llms.txt as a map and point the agent at the one category file or <code>.md</code> page the task needs. For a chat assistant that can fetch URLs, "read <a href="https://wingo-ui.com/components/data-table.md">https://wingo-ui.com/components/data-table.md</a>, then write an orders table with it" puts the real props in front of the model with one fetch.</p>
<p>What llms.txt cannot do is act. It cannot tell the agent which version your project has, whether your key covers an item, or write files with their dependencies. That is why it is the fallback in our rules and the MCP server is the default.</p>
<h2>Why install through a registry instead of pasting code?</h2>
<p>Because the registry and CLI write the exact files with their dependencies and record what they installed, and that record is the only way later releases can reach code you have edited. An agent that copies source out of a tool result skips all three steps.</p>
<p>Take the <a href="https://wingo-ui.com/components/login-page">Login Page</a> block. <code>npx wingo-ui@latest add login-page</code> writes three files into <code>components/blocks</code>, then pulls in the 16 registry items the block depends on (Alert, Avatar, Button, Checkbox, Input, OTP Input, Password Input, Separator, Toast and seven hooks and helpers), plus whatever those depend on, and installs its npm packages: <code>motion</code>, <code>lucide-react</code>, <code>react-hook-form</code> and <code>zod</code>. It records each item's version and content hash in <code>wingo-ui.json</code> and keeps a pristine copy under <code>.wingo-ui/base/</code>. When a release changes the block, <code>npx wingo-ui@latest update</code> runs a three-way merge between that pristine copy, your edited file and the new version, the way git merges branches.</p>
<p>An agent pasting code from <code>get_block</code> can miss a dependency, records nothing, and leaves you with files no tool can update. That is why every answer carries the install command and the rules say "install with the CLI" before anything about styling.</p>
<p>The registry also speaks the shadcn format. Free items install straight from their URL, for example <code>npx shadcn@latest add https://wingo-ui.com/r/button.json</code>, and Pro items install by name once the <code>@wingo-ui</code> registry with your key is in <code>components.json</code>, which also lets the shadcn MCP server install them. The trade-off is on our <a href="https://wingo-ui.com/docs/installation">installation page</a>: the shadcn CLI does not record versions, so <code>wingo-ui update</code> cannot merge later changes into files installed that way.</p>
<h2>What does a full agent session look like?</h2>
<p>Take a Next.js app that already has a <code>wingo-ui.json</code> and a plain prompt: "Build a sign-in page with two-step codes and an orders page with filters, using Wingo UI." With the server connected and the rules in place, the rules and server instructions lay out this path, and the prompt never names a tool:</p>
<ol>
<li><code>check_updates</code> with the items from <code>wingo-ui.json</code>, and a one-line report of what changed upstream.</li>
<li><code>search_components</code> with "sign in page" and the kind <code>block</code>. On our server the first hit is <code>login-page</code>.</li>
<li><code>get_block</code> for <code>login-page</code>: the props, the components it composes and the install command.</li>
<li><code>search_components</code> with "orders table with filters", then <code>get_component</code> for <code>data-table</code> with <code>includeSource: false</code>.</li>
<li><code>get_design_rules</code> for the <code>forms</code> and <code>mobile</code> topics.</li>
<li>One install for everything: <code>npx wingo-ui@latest add login-page data-table</code> (Button, Input and the rest come along as dependencies).</li>
<li>Two pages built from the installed components, configured through their props.</li>
</ol>
<p>Step 2 is the one a setup without the server skips. With nothing to search, an agent writes a sign-in form from scratch: two fields, a button, perhaps a "forgot password" link that leads nowhere. The block the search returns covers password, email link or code sign-in, Google, Apple and Microsoft buttons, two-step codes that lock for a minute after three wrong tries, and the whole password recovery flow. The page code is short because the block does the work:</p>
<pre><code class="language-tsx">// app/(auth)/login/page.tsx
"use client";

import { useRouter } from "next/navigation";
import { LoginPage } from "@/components/blocks/login-page";

// a non-2xx answer becomes the message the block shows above the form
async function post(url: string, body: unknown, error: string) {
  const response = await fetch(url, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!response.ok) throw new Error(error);
}

export default function Page() {
  const router = useRouter();
  return (
    &lt;LoginPage
      methods={["password"]}
      showProviders={false}
      twoFactor
      onSignIn={(values) =&gt; post("/api/login", values, "The email or the password is not correct")}
      onVerifyCode={(code, { backup }) =&gt; post("/api/login/verify", { code, backup }, "That code is not correct")}
      onForgot={(email) =&gt; post("/api/password/forgot", { email }, "We could not send the email. Try again.")}
      onSuccess={() =&gt; router.push("/orders")}
    /&gt;
  );
}
</code></pre>
<p>A rejected promise from <code>onSignIn</code> shows its message in an alert above the form, and <code>twoFactor</code> sends a correct password to the code step. Three wrong codes lock the input for a minute whatever your handler does.</p>
<p>The pitfall is in the handlers the code leaves out. The block ships simulated ones so its demo works, and every <code>on*</code> prop you skip keeps its simulation. Without <code>onVerifyCode</code>, the two-step view accepts <code>123456</code>. Without <code>showProviders={false}</code> or your own <code>onProvider</code>, the Google, Apple and Microsoft buttons spin for 1.2 seconds and then call <code>onSuccess</code>. An agent that wires <code>onSignIn</code> and stops ships a login page where a click on Google goes straight to <code>/orders</code> without signing anyone in. The <code>get_block</code> props table documents every one of these defaults, so the fix is the same as everywhere else: read the props, then wire each handler or turn the feature off.</p>
<p>The demo below runs on those simulated handlers, with the default methods and providers.</p>
<blockquote>
<p><strong>Live demo: Login Page.</strong> The original article embeds an interactive demo here (Sign in with any email and a password shorter than 8 characters to see the error alert, then choose "Email me a sign-in link" and send it to see the resend countdown). <a href="https://wingo-ui.com/blog/component-library-mcp-guide">Try it live on wingo-ui.com</a> or open the <a href="https://wingo-ui.com/components/login-page">Login Page docs</a>. Install: <code>npx wingo-ui@latest add login-page</code></p>
</blockquote>
<p>Without JavaScript, the demo shows the sign-in form: email and password fields, a "Remember me for 30 days" checkbox, the sign-in button, the emailed link option and buttons for Google, Apple and Microsoft.</p>
<p>The orders page follows the same pattern. The Data Table props the agent read decide most of the code: typed columns from <code>createColumns</code>, a badge column with a tone per status, a currency column with a footer sum, filters per column and quick filter chips.</p>
<pre><code class="language-tsx">// app/orders/orders-table.tsx
"use client";

import { useRouter } from "next/navigation";
import { Eye, Plus } from "lucide-react";
import { DataTable, createColumns } from "@/components/ui/data-table";

export type Order = {
  id: string;
  customer: string;
  city: string;
  status: "Paid" | "Pending" | "Refunded";
  total: number;
  placedAt: string;
};

const col = createColumns&lt;Order&gt;();
const columns = col([
  col.accessor("customer", { header: "Customer", filter: "text", mobile: "title" }),
  col.accessor("id", { header: "Order", mobile: "subtitle" }),
  col.accessor("city", { header: "City", filter: "multi-select" }),
  col.accessor("status", {
    header: "Status",
    type: "badge",
    badge: { Paid: { tone: "success" }, Pending: { tone: "warning" }, Refunded: { tone: "neutral" } },
    filter: "multi-select",
  }),
  col.accessor("total", { header: "Total", type: "currency", footer: "sum", filter: "number-range" }),
  col.accessor("placedAt", { header: "Placed", type: "date", filter: "date-range" }),
]);

export function OrdersTable({ orders }: { orders: Order[] }) {
  const router = useRouter();
  return (
    &lt;DataTable
      data={orders}
      columns={columns}
      getRowId={(order) =&gt; order.id}
      caption="Orders"
      locale="en-US"
      searchPlaceholder="Search orders"
      quickFilters={[{ id: "pending", label: "Pending", columnFilter: { id: "status", value: ["Pending"] } }]}
      toolbarActions={[{ label: "New order", icon: &lt;Plus /&gt;, primary: true, onSelect: () =&gt; router.push("/orders/new") }]}
      rowActions={(order) =&gt; [{ label: "View", icon: &lt;Eye /&gt;, onSelect: () =&gt; router.push(`/orders/${order.id}`) }]}
      persistKey="orders"
    /&gt;
  );
}
</code></pre>
<p>The page itself stays a server component that loads the rows and passes them down:</p>
<pre><code class="language-tsx">// app/orders/page.tsx
import { OrdersTable, type Order } from "./orders-table";

async function getOrders(): Promise&lt;Order[]&gt; {
  // your database query goes here
  return [{ id: "ORD-1042", customer: "Emma Carter", city: "New York", status: "Paid", total: 129, placedAt: "2026-10-02" }];
}

export default async function Page() {
  const orders = await getOrders();
  return (
    &lt;main className="mx-auto flex max-w-6xl flex-col gap-4 p-4 sm:p-8"&gt;
      &lt;h1 className="text-xl font-semibold text-foreground"&gt;Orders&lt;/h1&gt;
      &lt;OrdersTable orders={orders} /&gt;
    &lt;/main&gt;
  );
}
</code></pre>
<p>The split is deliberate. <code>getRowId</code>, <code>rowActions</code> and <code>onSelect</code> are functions (so is any custom <code>cell</code> renderer in the columns), and the App Router cannot pass functions from a server component to a client one. An agent that renders <code>&lt;DataTable&gt;</code> with inline callbacks straight from a server <code>page.tsx</code> gets that error at render time. Keeping the columns and callbacks in a client file and passing only plain rows avoids it.</p>
<p>Two more details come straight from the docs <code>get_component</code> returns. The <code>mobile</code> roles pick each card's title and subtitle below 768px, where the table becomes cards by default (<code>mobileLayout="cards"</code>). And <code>persistKey</code> remembers column visibility, order, widths, density and page size in local storage.</p>
<blockquote>
<p><strong>Live demo: Data Table.</strong> The original article embeds an interactive demo here (Search for a client, toggle the Overdue chip, then sort or filter from the toolbar; on a phone the rows become cards with a Load more button). <a href="https://wingo-ui.com/blog/component-library-mcp-guide">Try it live on wingo-ui.com</a> or open the <a href="https://wingo-ui.com/components/data-table">Data Table docs</a>. Install: <code>npx wingo-ui@latest add data-table</code></p>
</blockquote>
<p>Without JavaScript, the demo is a client table (client, city, status, owner, balance, last invoice) with a search field, quick filter chips such as Active and Overdue, and export and new client actions.</p>
<p>One more case to plan for: the Login Page and the Data Table are both Pro. Without a Pro key, steps 3 and 4 return the docs plus the refusal quoted earlier, and an agent that follows the rules reports the gap instead of rebuilding them from their props tables.</p>
<h2>How do agent-installed components stay up to date?</h2>
<p>The agent checks, you decide. At the start of UI work the rules ask for <code>check_updates</code> with the items in <code>wingo-ui.json</code>. The answer lists each update with its semver bump and the notes of every library release that touched the item, then the items that are current. Here is the real output for a project with an older Data Table and a current Button:</p>
<pre><code class="language-text">Latest Wingo UI release: 1.7.0.

1 update:
- **data-table**: 1.0.0 → 1.1.0 (minor)
  - 1.2.0: with no rows to show the empty state stands alone and compact, without column headers spread over nothing; `showHeaderWhenEmpty` keeps them.
  - 1.0.1: sticky header cells no longer drop down over the first row when the header is pinned while scrolling.

Update with `npx wingo-ui@latest update &lt;slug...&gt;` (a 3-way merge that keeps local edits); read breaking notes first.

Up to date: button
</code></pre>
<p>The numbers in front of the notes are library releases; the item itself goes from 1.0.0 to 1.1.0.</p>
<p>The Claude Code skill tells the agent to mention updates and run them only when you agree. An update is a code change in files you own, and it deserves the same review as any other diff. From the terminal the loop is three commands:</p>
<pre><code class="language-bash">npx wingo-ui@latest outdated        # installed items vs the latest release
npx wingo-ui@latest diff data-table # your file vs upstream
npx wingo-ui@latest update          # 3-way merge, with the release notes
</code></pre>
<p>The rules have their own version: <code>agents status</code> reports an old copy and <code>agents update</code> refreshes it. To keep your own edits to a managed file, delete its <code>wingo-ui:managed</code> line. The <a href="https://wingo-ui.com/docs/updates">updates docs</a> cover conflicts and the <code>--theirs</code> flag, and our guide to <a href="https://wingo-ui.com/blog/update-shadcn-components-without-losing-edits">updating shadcn components without losing edits</a> shows the same 3-way merge done by hand with git.</p>
<h2>How do I check what the agent built?</h2>
<p>Review it like a pull request from a fast junior developer who has read the docs. The server gets the first draft onto the right components; it does not replace looking at the result. Our checklist:</p>
<ul>
<li><strong>Hardcoded colors and radii.</strong> Search the diff for hex values, <code>rgb(</code> and arbitrary <code>rounded-[...]</code> classes. The rules ask for semantic tokens, and a stray hex color is easy to miss.</li>
<li><strong>Rebuilt components.</strong> A new file under <code>components/</code> that looks like a library item means the agent skipped search.</li>
<li><strong>Restyling instead of props.</strong> A long class list on a Button often stands in for a <code>variant</code>, <code>tone</code> or <code>size</code> the agent did not read.</li>
<li><strong>Simulated handlers.</strong> Blocks ship demo handlers for every <code>on*</code> prop. Compare the handlers the agent wired with the features left on screen, as in the login example above.</li>
<li><strong>Phone width and both themes.</strong> At 390px tables should become cards and menus should open as bottom sheets (our <a href="https://wingo-ui.com/blog/mobile-first-react-components">mobile-first React components guide</a> has the full list); light and dark should both hold up.</li>
<li><strong>Library files.</strong> <code>npx wingo-ui@latest diff &lt;slug&gt;</code> shows whether the agent edited one. Edits are allowed, but they become merge work on the next update.</li>
</ul>
<h2>Should you build an MCP server for your own design system?</h2>
<p>If several people or agents build UI on an internal library, yes. A UI component library MCP server is a smaller project than it sounds: ours is one file of about 220 lines on the TypeScript SDK (<code>@modelcontextprotocol/server</code>) plus four small tool modules, and most of the effort went into the data behind it. The protocol part was the easy bit. What we would tell a design system team, from building ours:</p>
<ul>
<li><strong>Start from metadata you already keep.</strong> Our tools read the same registry metadata that renders the docs pages. If your props live only in TypeScript types, extract them once into data the docs and the server share, or the two will drift.</li>
<li><strong>Keep every tool read-only.</strong> Let the agent read; let your CLI or package manager write.</li>
<li><strong>Return structured content and text.</strong> The model reads the text; scripts and future clients parse the structure.</li>
<li><strong>Make outputs small and name the next step.</strong> Slugs in search, the source behind a flag, the next tool at the end.</li>
<li><strong>Write errors as instructions.</strong> "Did you mean ..." and "do not re-create it" put the fix in front of the model at the moment it needs it.</li>
<li><strong>Keep server instructions under 2,048 characters</strong>, with the "when to use this server" sentence first. Ours are 187 characters over in 1.7.0, and the sentence that falls off is one we care about.</li>
<li><strong>Serve the release you deployed.</strong> One fresh server per request, backed by that deploy's registry, means agents never see stale props.</li>
<li><strong>Offer HTTP and stdio.</strong> Remote HTTP for clients that send headers, a local proxy for the rest.</li>
</ul>
<p>The <a href="https://wingo-ui.com/docs/mcp">MCP server for UI components</a> we run answers without an account, so you can point any client at it and see these choices from the agent's side before you design your own.</p>
<h2>Where should I start?</h2>
<p>To try it, connect the server with <code>npx wingo-ui@latest agents install claude</code> (or your agent's name) and ask for a screen in plain words. A free account adds the source of the 75 free items to MCP answers, including the <a href="https://wingo-ui.com/components/button">Button</a> and the <a href="https://wingo-ui.com/components/otp-input">OTP Input</a>; the CLI installs those without an account. The <a href="https://wingo-ui.com/components/login-page">Login Page</a> and <a href="https://wingo-ui.com/components/data-table">Data Table</a> are part of Pro, which covers all 326 items: $8 a month, $80 a year or $150 once, on the <a href="https://wingo-ui.com/pricing">pricing page</a>. The <a href="https://wingo-ui.com/docs/mcp">MCP server docs</a> have the per-client setup and troubleshooting.</p>
<p>If you maintain your own library, an MCP server is the cheapest way we know to make agents use it correctly. Pair it with five lines of rules and an installer that records versions, and the agent stops inventing your components and starts installing them.</p>
<h2>Components in this post</h2>
<ul>
<li><a href="https://wingo-ui.com/components/button">Button</a> (Free): The button every screen starts with: five variants, six tones, three sizes plus icon sizes, and a loading state that never jumps. Install: <code>npx wingo-ui@latest add button</code></li>
<li><a href="https://wingo-ui.com/components/login-page">Login Page</a> (Pro): The sign-in screen of Northwind CRM: password, email link or code, Google / Apple / Microsoft, two-step codes and the whole password recovery. Install: <code>npx wingo-ui@latest add login-page</code></li>
<li><a href="https://wingo-ui.com/components/data-table">Data Table</a> (Pro): The admin table on TanStack Table v9: search, filters, sorting, bulk actions, pinning, resizing, virtualization and cards on phones. Install: <code>npx wingo-ui@latest add data-table</code></li>
</ul>
<h2>FAQ</h2>
<h3>What is a component library MCP server?</h3>
<p>It is a Model Context Protocol server that exposes a UI library to coding agents as tools: search the catalog, read one component's props with defaults and usage, fetch the design rules and check installed items for updates. The agent calls these tools instead of guessing an API from its training data.</p>
<h3>Do I still need a rules file if I connect an MCP server?</h3>
<p>Yes. The server supplies facts, but an agent does not always reach for a tool on its own. A few lines in CLAUDE.md, AGENTS.md or a Cursor rule tell it to search before it writes UI, to install with the CLI and never to rebuild a component the library has.</p>
<h3>Is llms.txt enough without an MCP server?</h3>
<p>It helps assistants that can only read URLs, but it cannot act on anything. It cannot install files, compare your installed versions with the latest release or tell the agent which items your plan includes.</p>
<h3>Does the Wingo UI MCP server work without an account?</h3>
<p>Yes for docs: search, props, usage, design rules and theme tokens answer without a key. A key from a free account (email only) adds the source of the 75 free items, and a Pro key adds the source of everything else.</p>
<h3>Can I use the shadcn MCP server with Wingo UI?</h3>
<p>Yes. Add the @wingo-ui registry to components.json, with your API key for Pro items, and the shadcn MCP server can search and install from it. It does not serve our design rules or update checks, and the shadcn CLI does not record versions, so wingo-ui update cannot merge later releases into files installed that way.</p>
<h3>Which coding agents can connect to the Wingo UI MCP server?</h3>
<p>npx wingo-ui@latest agents install writes ready configs and rules for Claude Code, Codex, Gemini CLI, Grok and Cursor. Any other client that runs local commands can use the stdio proxy, npx -y wingo-ui@latest mcp.</p>
<hr />
<p><em>This article was originally published on the Wingo UI blog: <a href="https://wingo-ui.com/blog/component-library-mcp-guide">Component Library MCP: Make Agents Use Real Components</a>. The original has the live, interactive demos and is kept up to date.</em></p>
]]></content:encoded></item><item><title><![CDATA[Tailwind v4 Design Tokens: Colors, Dark Mode and Contrast]]></title><description><![CDATA[Tailwind v4 design tokens from a production React library: @theme inline, semantic surfaces, dark mode and a contrast check for CI.]]></description><link>https://wingo-ui.hashnode.dev/tailwind-v4-design-tokens-colors-dark-mode-and-contrast</link><guid isPermaLink="true">https://wingo-ui.hashnode.dev/tailwind-v4-design-tokens-colors-dark-mode-and-contrast</guid><category><![CDATA[Tailwind CSS]]></category><category><![CDATA[CSS]]></category><category><![CDATA[Design Systems]]></category><dc:creator><![CDATA[Serban Dan Rusu]]></dc:creator><pubDate>Sat, 24 Oct 2026 08:17:48 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ad6a430d0d0b34960b508b1/27870690-a618-4761-b6c2-31c785720859.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2>TL;DR</h2>
<p>Tailwind v4 design tokens are CSS variables: keep the values that change between light and dark in :root and .dark, then map them to utilities with @theme inline, so bg-surface or text-muted-foreground follow the theme with no dark: prefix. A complete set names roles instead of colors: surfaces in the order they stack, text levels, tones split into a solid fill and a readable text color, plus radius, elevation, z-index and motion scales. Check every text and surface pair for 4.5:1 contrast in both themes with a script, because a color that passes on white often fails on a dark popover.</p>
<p>Tailwind CSS v4 moved configuration out of <code>tailwind.config.js</code> and into CSS, and many guides stop at the first step: put a few brand colors in <code>@theme</code> and call them tokens. A component library needs more than a palette. It needs Tailwind v4 design tokens for every decision a component makes: which surface sits in front of which, how muted secondary text can get, what a warning uses as a fill and as text, how round a 32px button is next to a 48px one, how a popover casts a shadow on a black page, and which layer a select opens on inside a dialog. This guide is the token system we use in Wingo UI, a React 19 and Tailwind CSS v4 component library, with the real CSS and the contrast numbers behind each choice. We build Wingo UI, so we are not neutral; outside facts link to their sources and are dated.</p>
<h2>What are design tokens in Tailwind v4?</h2>
<p>Design tokens in Tailwind v4 are CSS custom properties. Tailwind v4 theme variables, the ones you declare inside an <code>@theme</code> block, do two jobs at once: they are CSS variables you can read with <code>var()</code>, and their namespace decides which utilities Tailwind generates. <code>--color-surface</code> creates <code>bg-surface</code>, <code>text-surface</code> and <code>border-surface</code>; <code>--shadow-raised</code> creates <code>shadow-raised</code>. Tailwind CSS v4.0 shipped this CSS-first configuration on January 22, 2025 (<a href="https://tailwindcss.com/blog/tailwindcss-v4">release post</a>), and the <a href="https://tailwindcss.com/docs/theme">theme variables docs</a> list the namespaces. We run Tailwind 4.3.3 as of October 2026.</p>
<p>The namespaces a component library uses most:</p>
<table>
<thead>
<tr>
<th>Namespace</th>
<th>Utilities it creates</th>
<th>What we keep there</th>
</tr>
</thead>
<tbody><tr>
<td><code>--color-*</code></td>
<td><code>bg-*</code>, <code>text-*</code>, <code>border-*</code>, <code>ring-*</code>, <code>fill-*</code></td>
<td>surfaces, text levels, tones, borders, the focus ring</td>
</tr>
<tr>
<td><code>--shadow-*</code></td>
<td><code>shadow-*</code></td>
<td>three elevation levels</td>
</tr>
<tr>
<td><code>--radius-*</code></td>
<td><code>rounded-*</code></td>
<td>a legacy scale that new code avoids (see the radius section)</td>
</tr>
<tr>
<td><code>--z-index-*</code></td>
<td><code>z-*</code></td>
<td>seven stacking layers</td>
</tr>
<tr>
<td><code>--ease-*</code>, <code>--transition-duration-*</code></td>
<td><code>ease-*</code>, <code>duration-*</code></td>
<td>motion curves and durations</td>
</tr>
<tr>
<td><code>--font-*</code></td>
<td><code>font-*</code></td>
<td>font families</td>
</tr>
<tr>
<td><code>--spacing</code></td>
<td><code>p-*</code>, <code>m-*</code>, <code>gap-*</code>, <code>w-*</code>, <code>h-*</code></td>
<td>the 4px grid (<code>0.25rem</code>)</td>
</tr>
</tbody></table>
<p><code>--z-index-*</code> and <code>--transition-duration-*</code> are not in the docs table as of October 2026, but Tailwind 4.3 compiles them: our CSS has <code>.z-modal { z-index: var(--z-modal) }</code>.</p>
<h2>How should you layer Tailwind v4 design tokens?</h2>
<p>Use three layers. Raw values that change between themes go in <code>:root</code> and <code>.dark</code>. A mapping in <code>@theme inline</code> turns them into utilities. Components then paint through a few runtime variables that a prop sets, so a <code>tone</code> or a custom <code>color</code> never needs its own class.</p>
<p>Here is a trimmed but working excerpt of our <code>app/globals.css</code>, with the real values:</p>
<pre><code class="language-css">@import "tailwindcss";

@custom-variant dark (&amp;:is(.dark *));

:root {
  --background: oklch(1 0 0);
  --foreground: oklch(0.3211 0 0);
  --surface: #ffffff;
  --popover: oklch(1 0 0);
  --muted-foreground: oklch(0.52 0.0234 264.3637);
  --success: #15803d;
  --success-foreground: #ffffff;
  --success-soft: #e7f6ec;
  --success-soft-foreground: #166534;
  --elevation-raised: 0 0 0 1px rgb(0 0 0 / 0.07), 0 1px 2px rgb(0 0 0 / 0.05), 0 2px 8px -2px rgb(0 0 0 / 0.06);
}

.dark {
  --background: oklch(0 0 0);
  --foreground: oklch(0.9219 0 0);
  --surface: #1a1a1a;
  --popover: #262626;
  --muted-foreground: oklch(0.7155 0 0);
  --success-soft: #0d2315;
  --success-soft-foreground: #4ade80;
  --elevation-raised: inset 0 1px 0 0 rgb(255 255 255 / 0.06), 0 0 0 1px rgb(255 255 255 / 0.08), 0 1px 2px rgb(0 0 0 / 0.5);
}

@theme inline {
  --color-background: var(--background);
  --color-foreground: var(--foreground);
  --color-surface: var(--surface);
  --color-popover: var(--popover);
  --color-muted-foreground: var(--muted-foreground);
  --color-success: var(--success);
  --color-success-foreground: var(--success-foreground);
  --color-success-soft: var(--success-soft);
  --color-success-soft-foreground: var(--success-soft-foreground);
  --shadow-raised: var(--elevation-raised);
}
</code></pre>
<p>The success fill and its white text are the same in both themes (5.02:1), so this excerpt declares them only in <code>:root</code> and <code>.dark</code> inherits them. Everything that changes is redeclared under <code>.dark</code>, and a component uses the same classes everywhere:</p>
<pre><code class="language-tsx">export function InvoiceRow() {
  return (
    &lt;div className="flex items-center justify-between rounded-2xl bg-surface p-4 text-foreground shadow-raised"&gt;
      &lt;div&gt;
        &lt;p className="text-[15px] font-medium"&gt;Invoice 1042&lt;/p&gt;
        &lt;p className="text-sm text-muted-foreground"&gt;Emma Carter, New York&lt;/p&gt;
      &lt;/div&gt;
      &lt;span className="rounded-full bg-success-soft px-2 py-0.5 text-xs font-medium text-success-soft-foreground"&gt;Paid&lt;/span&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<p>The raw names follow shadcn/ui's convention on purpose: <code>--background</code>, <code>--card</code>, <code>--popover</code> and <code>--muted-foreground</code> are shadcn names, and the ones shadcn does not have (<code>--surface</code>, <code>--canvas</code>, the <code>--success-*</code> family) use the same <code>name</code> and <code>name-foreground</code> pattern. A project that already has shadcn tokens can take ours without renaming anything.</p>
<h2>When do you need Tailwind v4 @theme inline?</h2>
<p>Use <code>@theme inline</code> whenever a theme variable points at another variable, which is every token that changes with the theme. The <a href="https://tailwindcss.com/docs/theme">Tailwind docs</a> say it directly: with <code>inline</code>, "the utility class will use the theme variable value instead of referencing the actual theme variable". Here is what that means in compiled CSS:</p>
<pre><code class="language-css">/* @theme inline { --color-surface: var(--surface); } */
.bg-surface {
  background-color: var(--surface);
}

/* @theme { --color-surface: var(--surface); } */
:root, :host {
  --color-surface: var(--surface);
}
.bg-surface {
  background-color: var(--color-surface);
}
</code></pre>
<p>The difference shows up below the root. A custom property inherits its computed value, and <code>var()</code> is substituted where the property is declared. In the second version <code>--color-surface</code> is resolved once, on <code>:root</code>, and every element inherits that result. A <code>.dark</code> class on <code>&lt;html&gt;</code> still works, because <code>&lt;html&gt;</code> is the root. A dark section inside a light page (<code>&lt;section className="dark"&gt;</code>), a dark preview pane or a <code>data-theme</code> attribute on a container does not: the section inherits the light value that <code>:root</code> already resolved. With <code>inline</code>, <code>bg-surface</code> reads <code>var(--surface)</code> on the element itself, so it picks up the nearest override. The Tailwind docs explain the option with the same kind of parent and child example.</p>
<p>One catch remains with <code>inline</code>: if you write <code>var(--color-surface)</code> yourself, in an arbitrary value like <code>bg-[var(--color-surface)]</code> or in plain CSS, Tailwind 4.3 emits <code>--color-surface: var(--surface)</code> on <code>:root</code> to satisfy it, and that reference is resolved on the root again. Inside components, read the raw variable instead: <code>bg-(--surface)</code> or <code>var(--surface)</code>.</p>
<p>Two more rules from the docs: theme variables must sit at the top level, never under a selector or a media query, so per-theme values live outside <code>@theme</code> anyway; and Tailwind only emits the theme variables your code uses, unless you write <code>@theme static</code>. The raw variables in <code>:root</code> always ship, which helps when a chart library reads a token with <code>getComputedStyle</code>.</p>
<h2>Which semantic color tokens does a component library need?</h2>
<p>Name colors by role, never by hue. A component library needs surfaces in the order they stack, text levels with a contrast floor, translucent tints for hover and fills, a border and a focus ring, and status tones. With semantic color tokens, components never contain <code>bg-gray-100</code> or <code>text-blue-600</code>, so a theme can change every value without touching a component.</p>
<h3>Surfaces, back to front</h3>
<table>
<thead>
<tr>
<th>Token</th>
<th>Light</th>
<th>Dark</th>
<th>Use</th>
</tr>
</thead>
<tbody><tr>
<td><code>background</code></td>
<td><code>#FFFFFF</code></td>
<td><code>#000000</code></td>
<td>the page</td>
</tr>
<tr>
<td><code>canvas</code></td>
<td><code>#F6F7F9</code></td>
<td><code>#0E0E10</code></td>
<td>the page of an app (dashboards, admin), under white panels</td>
</tr>
<tr>
<td><code>card</code></td>
<td><code>#EBEBEB</code></td>
<td><code>#121212</code></td>
<td>flat gray tiles with no shadow</td>
</tr>
<tr>
<td><code>surface</code></td>
<td><code>#FFFFFF</code> with <code>shadow-raised</code></td>
<td><code>#1A1A1A</code></td>
<td>raised panels: list groups, stat cards, forms</td>
</tr>
<tr>
<td><code>popover</code></td>
<td><code>#FFFFFF</code> with <code>shadow-floating</code></td>
<td><code>#262626</code></td>
<td>everything that floats: menus, selects, dialogs, sheets, toasts</td>
</tr>
<tr>
<td><code>primary</code></td>
<td><code>#000000</code></td>
<td><code>#FAFAFA</code></td>
<td>the neutral solid fill and tooltips</td>
</tr>
</tbody></table>
<p>In dark mode the surfaces get lighter as they come forward: <code>#000</code>, <code>#121212</code>, <code>#1A1A1A</code>, <code>#262626</code>. In light mode most of them are white, so the shadow does the separating. <code>canvas</code> exists because a dashboard on a white page has nothing to set white cards against; a cool gray page makes every <code>bg-surface</code> panel read as a card.</p>
<h3>Text levels</h3>
<table>
<thead>
<tr>
<th>Level</th>
<th>Class</th>
<th>Use</th>
<th>Lowest ratio in our token pairs</th>
</tr>
</thead>
<tbody><tr>
<td>Primary</td>
<td><code>text-foreground</code></td>
<td>titles, values, labels</td>
<td>10.60:1 (light card)</td>
</tr>
<tr>
<td>Secondary</td>
<td><code>text-muted-foreground</code></td>
<td>descriptions, helper text, timestamps</td>
<td>4.62:1 (light card)</td>
</tr>
<tr>
<td>Disabled</td>
<td><code>text-foreground/45</code></td>
<td>disabled labels only</td>
<td>none required</td>
</tr>
<tr>
<td>Tone text</td>
<td><code>text-success-soft-foreground</code> and its siblings</td>
<td>errors, deltas, links</td>
<td>5.28:1 (brand on the light card)</td>
</tr>
</tbody></table>
<p><code>text-muted-foreground</code> is the floor for anything people must read. The disabled level has no contrast target because <a href="https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html">WCAG 2.2 success criterion 1.4.3</a> exempts text in inactive controls, which is why it is never used for anything else.</p>
<h3>Tints instead of gray fills</h3>
<p>Hover states, field fills, chips, tracks and skeletons use translucent foreground: <code>bg-foreground/5</code>, <code>/7</code>, <code>/10</code> and <code>/15</code>, plus <code>border-foreground/10</code> for hairlines. Tailwind 4.3 compiles the opacity modifier to <code>color-mix()</code>, with the plain color as a fallback:</p>
<pre><code class="language-css">.bg-foreground\/5 {
  background-color: var(--foreground);
  @supports (color: color-mix(in lab, red, red)) {
    background-color: color-mix(in oklab, var(--foreground) 5%, transparent);
  }
}
</code></pre>
<p>A tint is relative to whatever sits behind it, so it moves one step toward the text color on every surface, in both themes. A fixed fill cannot do that. Our dark <code>--muted</code> is <code>#171717</code>: lighter than the black page and darker than the <code>#1A1A1A</code> surface, so the same field would look raised on one and sunken on the other. The full-strength fallback only shows in browsers without <code>color-mix()</code>, which are all below Tailwind's <a href="https://tailwindcss.com/docs/compatibility">browser baseline</a> of Chrome 111, Safari 16.4 and Firefox 128.</p>
<p>shadcn/ui's <code>secondary</code> and <code>accent</code> stay in our file so ported components keep working, but our <code>--accent</code> is an orange tint, and new components never use either.</p>
<h2>How do status colors work without a class per color?</h2>
<p>Give each status tone four tokens: a solid fill, the text on that fill, a soft tinted surface, and tone text that reads on the soft surface and on every neutral surface. Then let components paint through five generic variables that one class sets. That is the whole idea behind the free <a href="https://wingo-ui.com/components/tones">Tones utility</a>.</p>
<p>The four tokens per tone, for brand, success, warning, destructive and info:</p>
<pre><code class="language-css">--success: #15803d;                 /* bg-success: buttons, solid badges, progress */
--success-foreground: #ffffff;      /* text on the fill */
--success-soft: #e7f6ec;            /* bg-success-soft: soft badges, alerts, selected rows */
--success-soft-foreground: #166534; /* colored text on the tint and on any neutral surface */
</code></pre>
<p>The split between fill and tone text is the easiest rule to skip, and it is where dark mode breaks. Fills are tuned to carry white text, and they make poor text. <code>text-destructive</code> (<code>#DC2626</code>) on our dark popover is 3.13:1; <code>text-destructive-soft-foreground</code> (<code>#FF8A80</code>) on the same surface is 6.63:1. The warning fill is amber (<code>#F5A524</code>) at 2.04:1 against white, so it carries dark text (<code>#2B1700</code>, 8.40:1) and is never used as text on a neutral surface.</p>
<h3>Component variables</h3>
<p>A component with a <code>tone</code> prop does not map tokens itself. <code>toneClasses[tone]</code> sets five variables, and every variant paints with them:</p>
<pre><code class="language-ts">// lib/tones.ts (excerpt)
success:
  "[--tone:var(--success)] [--tone-foreground:var(--success-foreground)] [--tone-soft:var(--success-soft)] [--tone-soft-foreground:var(--success-soft-foreground)] [--tone-border:color-mix(in_oklab,var(--success)_45%,transparent)]",
</code></pre>
<p>The neutral tone maps <code>--tone</code> to <code>--primary</code> (black in light, near-white in dark) and builds its soft surface from <code>color-mix(in oklab, var(--foreground) 7%, transparent)</code>, which is why a neutral soft button works on any surface. A badge built this way:</p>
<pre><code class="language-tsx">import type { ComponentProps } from "react";
import { colorVars, toneClasses, type Tone } from "@/lib/tones";
import { cn } from "@/lib/utils";

type StatusBadgeProps = ComponentProps&lt;"span"&gt; &amp; { tone?: Tone; color?: string };

export function StatusBadge({ tone = "neutral", color, className, style, ...props }: StatusBadgeProps) {
  return (
    &lt;span
      data-slot="status-badge"
      className={cn(
        toneClasses[tone],
        "inline-flex rounded-full bg-(--tone-soft) px-2 py-0.5 text-xs font-medium text-(--tone-soft-foreground)",
        className,
      )}
      style={{ ...colorVars(color), ...style }}
      {...props}
    /&gt;
  );
}
</code></pre>
<p><code>bg-(--tone-soft)</code> is Tailwind v4's shorthand for <code>bg-[var(--tone-soft)]</code>. The free <a href="https://wingo-ui.com/components/badge">Badge</a> is the full version of this pattern, with solid, soft and outline variants. <code>colorVars(color)</code> builds the same five variables from any CSS color as an inline style. Its text-on-fill value uses relative color syntax, <code>oklch(from &lt;color&gt; clamp(0, (0.7 - l) * 1000, 1) 0 0)</code>: white when the color's OKLCH lightness is below 0.7, black above. As of October 2026, <a href="https://caniuse.com/css-relative-colors">caniuse</a> lists full support for relative color syntax from Chrome 131, Safari 18 and Firefox 133, and partial support before that, so full support arrived later than Tailwind's own baseline. Where a browser cannot compute the value, the declaration is invalid at computed-value time and the text inherits its parent's color. If you support those browsers, force the text color with <code>className="[--tone-foreground:white]"</code>.</p>
<blockquote>
<p><strong>Live demo: Tones.</strong> The original article embeds an interactive demo here (Each row paints one tone three ways from the same five variables, and the last row is built by colorVars from one hex value, #0EA5E9. Switch the site theme: the solid fills stay, while the soft tints and the tone text take their dark values). <a href="https://wingo-ui.com/blog/tailwind-v4-design-tokens">Try it live on wingo-ui.com</a> or open the <a href="https://wingo-ui.com/components/tones">Tones docs</a>. Install: <code>npx wingo-ui@latest add tones</code></p>
</blockquote>
<p>The free <a href="https://wingo-ui.com/components/button">Button</a> is the reference consumer. Its variant map contains no color at all:</p>
<pre><code class="language-ts">const VARIANTS: Record&lt;ButtonVariant, string&gt; = {
  solid: "bg-(--tone) text-(--tone-foreground) shadow-raised",
  soft: "bg-(--tone-soft) text-(--tone-soft-foreground)",
  outline: "border border-(--tone-border) text-(--tone-soft-foreground)",
  ghost: "text-(--tone-soft-foreground)",
  link: "rounded-[4px] text-(--tone-soft-foreground) underline decoration-current/35 decoration-1 underline-offset-4 transition-[color,text-decoration-color] hover:decoration-current",
};
</code></pre>
<p>Six tones times five variants, plus any custom color, from five lines. Hover and press are a layer of <code>currentColor</code> at 8% and 12% opacity, so they also work for every tone without extra classes.</p>
<h2>How do you design dark mode with tokens?</h2>
<p>Treat dark as a second set of values for the same roles, picked by hand, and switch it with a class. Components keep the same classes in both themes, so almost nothing in a component needs a <code>dark:</code> prefix.</p>
<p>The variant: our tokens, like shadcn/ui's, use <code>@custom-variant dark (&amp;:is(.dark *));</code>. The <a href="https://tailwindcss.com/docs/dark-mode">Tailwind dark mode docs</a> show <code>&amp;:where(.dark, .dark *)</code>, which also matches the element that carries the class and adds no specificity. With the class on <code>&lt;html&gt;</code>, both behave the same for normal use. The class itself comes from <a href="https://github.com/pacocoursey/next-themes">next-themes</a>, which writes it before the first paint: wrap the app in its <code>ThemeProvider</code> with <code>attribute="class"</code> (the full provider file is in the props section below), and render <code>&lt;html lang="en" suppressHydrationWarning&gt;</code> in the root layout, because next-themes changes the class before React hydrates:</p>
<pre><code class="language-tsx">// app/layout.tsx
import type { ReactNode } from "react";
import { Providers } from "./providers";
import "./globals.css";

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    &lt;html lang="en" suppressHydrationWarning&gt;
      &lt;body className="bg-background text-foreground"&gt;
        &lt;Providers&gt;{children}&lt;/Providers&gt;
      &lt;/body&gt;
    &lt;/html&gt;
  );
}
</code></pre>
<p>What a designed dark theme looks like in practice, from our token file:</p>
<ul>
<li><strong>Surfaces lighten as they come forward.</strong> <code>#000</code> page, <code>#121212</code> card, <code>#1A1A1A</code> surface, <code>#262626</code> popover.</li>
<li><strong>Fills stay, soft surfaces flip.</strong> The brand fill is <code>#D63F00</code> in both themes, so white text keeps 4.59:1. Its soft surface goes from <code>#FFEFE6</code> to <code>#2A1509</code>, and its text from <code>#B03400</code> to <code>#FF8A4C</code>.</li>
<li><strong>The neutral fill inverts.</strong> <code>primary</code> is black with white text in light mode and <code>#FAFAFA</code> with <code>#171717</code> in dark, so the neutral solid button keeps 21:1 in light and 17.16:1 in dark.</li>
<li><strong>Elevation changes technique.</strong> A drop shadow barely shows on black, so dark shadows lean on a lit top edge and a white hairline (see the elevation levels below).</li>
<li><strong>The focus ring shifts.</strong> <code>--ring</code> is <code>oklch(0.60 0.20 36.93)</code> in light and <code>oklch(0.6617 0.2215 36.93)</code> in dark, deeper on white so a 2px outline keeps 3:1 on white and on the gray card.</li>
<li><strong>Some components need an exception.</strong> Our soft <a href="https://wingo-ui.com/components/card">Card</a> uses <code>bg-card</code> in light but <code>bg-foreground/7</code> in dark, because the dark <code>--card</code> is the same gray as a dark sidebar or stage and the card would vanish there. An inversion script never makes that call.</li>
</ul>
<h2>How do you check contrast for every token pair?</h2>
<p>List the pairs that are meant to be used together, convert both colors to sRGB, compute the WCAG ratio in both themes, and fail the build below 4.5:1. WCAG asks for 4.5:1 for body text and 3:1 for large text (<a href="https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html">1.4.3</a>), and 3:1 for UI components and focus indicators against what is next to them (<a href="https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html">1.4.11</a>).</p>
<p>Our check covers 54 pairs per theme, 108 in total, and reports 0 below 4.5:1 as of release 1.7.0. The tightest pairs are <code>brand-foreground</code> on <code>brand</code> at 4.59:1 and <code>muted-foreground</code> on the light <code>card</code> at 4.62:1. Here is a self-contained version you can drop into <code>scripts/</code> and run with <code>npx tsx scripts/check-contrast.ts</code>. It reads hex and <code>oklch()</code> values (decimal or percent lightness), follows <code>var()</code> references and exits with code 1 on a failure. It expects the token names used in this guide; if yours differ, edit <code>SURFACES</code>, <code>TONES</code> and <code>PAIRS</code>:</p>
<pre><code class="language-ts">// scripts/check-contrast.ts
import { readFileSync } from "node:fs";

type Rgb = [number, number, number];
const css = readFileSync("app/globals.css", "utf8").replace(/\/\*[\s\S]*?\*\//g, "");

// every --name: value inside the blocks that start with this selector
function vars(selector: string) {
  const map = new Map&lt;string, string&gt;();
  const block = new RegExp(`(?:^|\\n)${selector.replace(".", "\\.")}\\s*\\{([^}]*)\\}`, "g");
  for (const m of css.matchAll(block))
    for (const d of m[1].matchAll(/(--[\w-]+)\s*:\s*([^;]+);/g)) map.set(d[1], d[2].trim());
  return map;
}

export function oklchToRgb(l: number, c: number, h: number): Rgb {
  const a = c * Math.cos((h * Math.PI) / 180);
  const b = c * Math.sin((h * Math.PI) / 180);
  const l3 = (l + 0.3963377774 * a + 0.2158037573 * b) ** 3;
  const m3 = (l - 0.1055613458 * a - 0.0638541728 * b) ** 3;
  const s3 = (l - 0.0894841775 * a - 1.291485548 * b) ** 3;
  const linear = [
    4.0767416621 * l3 - 3.3077115913 * m3 + 0.2309699292 * s3,
    -1.2684380046 * l3 + 2.6097574011 * m3 - 0.3413193965 * s3,
    -0.0041960863 * l3 - 0.7034186147 * m3 + 1.707614701 * s3,
  ];
  // clamp into srgb, then gamma-encode
  return linear.map((v) =&gt; {
    const x = Math.min(1, Math.max(0, v));
    return x &lt;= 0.0031308 ? 12.92 * x : 1.055 * x ** (1 / 2.4) - 0.055;
  }) as Rgb;
}

function parse(value: string, map: Map&lt;string, string&gt;): Rgb {
  const ref = value.match(/^var\((--[\w-]+)\)$/);
  if (ref) return parse(map.get(ref[1]) ?? "", map);
  if (value.startsWith("#")) {
    const hex = value.length === 4 ? [...value.slice(1)].map((ch) =&gt; ch + ch).join("") : value.slice(1);
    return [0, 2, 4].map((i) =&gt; parseInt(hex.slice(i, i + 2), 16) / 255) as Rgb;
  }
  const ok = value.match(/^oklch\(\s*([\d.]+)(%?)\s+([\d.]+)\s+([\d.]+)\s*\)$/);
  if (ok) return oklchToRgb(ok[2] ? +ok[1] / 100 : +ok[1], +ok[3], +ok[4]);
  throw new Error(`cannot parse "${value}"`);
}

const channel = (c: number) =&gt; (c &lt;= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
const luminance = ([r, g, b]: Rgb) =&gt; 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);

export function contrast(fg: Rgb, bg: Rgb) {
  const [hi, lo] = [luminance(fg), luminance(bg)].sort((x, y) =&gt; y - x);
  return (hi + 0.05) / (lo + 0.05);
}

const SURFACES = ["background", "card", "popover", "surface", "canvas"];
const TONES = ["brand", "success", "warning", "info", "destructive"];
const PAIRS = [
  ["foreground", "background"],
  ...SURFACES.map((s) =&gt; ["muted-foreground", s]),
  ...TONES.flatMap((t) =&gt; [
    [`${t}-foreground`, t],
    [`${t}-soft-foreground`, `${t}-soft`],
    ...SURFACES.map((s) =&gt; [`${t}-soft-foreground`, s]),
  ]),
];

const light = vars(":root");
const themes = { light, dark: new Map([...light, ...vars(".dark")]) };
let failures = 0;
for (const [theme, map] of Object.entries(themes)) {
  for (const [fg, bg] of PAIRS) {
    const ratio = contrast(parse(map.get(`--${fg}`) ?? "", map), parse(map.get(`--${bg}`) ?? "", map));
    if (ratio &lt; 4.5) {
      failures++;
      console.log(`FAIL ${theme}: ${fg} on ${bg} is ${ratio.toFixed(2)}:1`);
    }
  }
}
console.log(`${PAIRS.length * 2} pairs checked, ${failures} below 4.5:1`);
process.exitCode = failures ? 1 : 0;
</code></pre>
<p>This trimmed version checks 82 pairs. Our full script adds the remaining surface, primary and legacy pairs and composites translucent values over the page first. The pair list is the valuable part: it encodes which text may sit on which surface, so a new token is checked the day it is added. Run it in CI next to the type check.</p>
<h2>Should you write Tailwind v4 OKLCH values or hex?</h2>
<p>Use whichever format you can reason about; the format does not change contrast. Tailwind's own palette moved "from <code>rgb</code> to <code>oklch</code>, taking advantage of the wider gamut" in v4.0 (<a href="https://tailwindcss.com/blog/tailwindcss-v4">release post</a>), and its <a href="https://tailwindcss.com/docs/colors">colors reference</a> lists every Tailwind v4 OKLCH value, such as <code>--color-red-500: oklch(63.7% 0.237 25.331)</code>. OKLCH earns its place when you derive colors in code, because lightness is its own channel: you can move it while hue and chroma stay put, and the result still looks like the same color.</p>
<p>In our file, neutrals are <code>oklch()</code> with zero chroma (<code>oklch(0.3211 0 0)</code> is the light foreground), and the tone values are hex, tuned by hand against the contrast check. The <a href="https://wingo-ui.com/theme">theme generator</a> goes the other way: you pick a base color for each tone (brand, success, warning, danger, info), and it derives that tone's fill, soft surface and text in OKLCH. For light mode it puts the soft surface at lightness 0.965 with chroma capped at 0.035, then walks the tone text's lightness down from 0.6 in 0.005 steps until it reaches 4.6:1 on the soft surface and on every neutral surface. Move <code>oklchToRgb</code> and <code>contrast</code> from the contrast script into their own module, <code>scripts/color.ts</code>, so importing them does not run the check, and the same search is a few lines:</p>
<pre><code class="language-ts">import { contrast, oklchToRgb } from "./color";

type Rgb = [number, number, number];

// keep hue and chroma, walk lightness until the color reads on every surface it can sit on
export function findReadable(c: number, h: number, surfaces: Rgb[], from: number, to: number, target = 4.6): Rgb {
  const step = from &lt; to ? 0.005 : -0.005;
  for (let l = from; step &gt; 0 ? l &lt;= to : l &gt;= to; l += step) {
    const rgb = oklchToRgb(l, c, h);
    if (Math.min(...surfaces.map((bg) =&gt; contrast(rgb, bg))) &gt;= target) return rgb;
  }
  return oklchToRgb(to, c, h);
}
</code></pre>
<p>The target is 4.6 rather than 4.5 so rounding to hex cannot push a color under the line. One caution: a high-chroma OKLCH value can fall outside sRGB, and what a screen then shows depends on the display and the browser. Keep token values inside sRGB, or at least measure the clamped sRGB value, as <code>oklchToRgb</code> does.</p>
<h2>How do radius, elevation, z-index and motion become tokens?</h2>
<p>They become scales with a fixed set of steps, each with one job, so a component picks a step instead of a number. A Tailwind v4 design system that only tokenizes color still ends up with one-off shadows and a <code>z-[9999]</code> somewhere.</p>
<h3>Radius follows size</h3>
<p>Tie radius to the control's size. A 32px and a 48px button only read as the same shape when the larger one has the larger radius. Our controls copy this map from the Button:</p>
<pre><code class="language-ts">type Scale = "sm" | "md" | "lg";
type ButtonRadius = "none" | "sm" | "md" | "lg" | "full";

const RADII: Record&lt;ButtonRadius, Record&lt;Scale, string&gt;&gt; = {
  none: { sm: "rounded-none", md: "rounded-none", lg: "rounded-none" },
  sm: { sm: "rounded-[6px]", md: "rounded-[8px]", lg: "rounded-[10px]" },
  md: { sm: "rounded-[8px]", md: "rounded-[10px]", lg: "rounded-[12px]" },
  lg: { sm: "rounded-[10px]", md: "rounded-[14px]", lg: "rounded-[16px]" },
  full: { sm: "rounded-full", md: "rounded-full", lg: "rounded-full" },
};
</code></pre>
<p>Surfaces use <code>rounded-2xl</code> (16px) for cards, menus and popovers, <code>rounded-3xl</code> (24px) for dialogs, and <code>rounded-t-[28px]</code> for bottom sheets. Anything inside a padded surface takes the outer radius minus the padding: a <code>rounded-2xl p-1.5</code> menu has <code>rounded-[10px]</code> items. Sizes follow the same thinking: 32, 40 and 48px, with the 40px size growing to 44px on touch screens, the rule from our <a href="https://wingo-ui.com/blog/mobile-first-react-components">mobile-first React components guide</a>.</p>
<p>The trap is a global radius token. Our file still carries shadcn/ui's older scale for ported components: <code>--radius: 0.375rem</code>, <code>--radius-lg: var(--radius)</code> and <code>--radius-sm: calc(var(--radius) - 4px)</code>. That remaps Tailwind's <code>rounded-sm</code>, <code>rounded-md</code>, <code>rounded-lg</code> and <code>rounded-xl</code> to 2, 4, 6 and 10px everywhere, including markup pasted from Tailwind's docs (the compiled <code>.rounded-lg</code> is <code>border-radius: var(--radius)</code>), so new components never use those four classes. As of October 2026, <a href="https://ui.shadcn.com/docs/theming">shadcn/ui's theming docs</a> derive the scale by multiplication instead (<code>--radius-sm: calc(var(--radius) * 0.6)</code> up to <code>--radius-4xl</code>), which has the same project-wide reach. If you theme radius globally, own every component that uses it.</p>
<h3>Three elevation levels</h3>
<p>Three levels cover a library: <code>shadow-raised</code> for buttons, cards and thumbs, <code>shadow-floating</code> for popovers, menus and toasts, <code>shadow-modal</code> for dialogs and sheets. Each level carries its own 1px hairline, so an elevated surface needs no border:</p>
<pre><code class="language-css">:root {
  --elevation-floating: 0 0 0 1px rgb(0 0 0 / 0.06), 0 4px 12px -2px rgb(0 0 0 / 0.08), 0 16px 36px -10px rgb(0 0 0 / 0.16);
}
.dark {
  /* on a black page a drop shadow barely shows: a lit top edge and a hairline do the work */
  --elevation-floating: inset 0 1px 0 0 rgb(255 255 255 / 0.07), 0 0 0 1px rgb(255 255 255 / 0.09), 0 8px 24px -4px rgb(0 0 0 / 0.6), 0 20px 48px -12px rgb(0 0 0 / 0.7);
}
@theme inline {
  --shadow-floating: var(--elevation-floating);
}
</code></pre>
<p>An interactive Card moves between two of these levels on hover, and the lift runs as a CSS transition on a spring curve (below), so a grid of fifty cards animates without JavaScript.</p>
<blockquote>
<p><strong>Live demo: Card.</strong> The original article embeds an interactive demo here (Hover the card to see it lift from shadow-raised to shadow-floating, then press it to see the dip to 98% (phones get only the press). Switch the site theme to see the dark elevation draw a lit top edge instead). <a href="https://wingo-ui.com/blog/tailwind-v4-design-tokens">Try it live on wingo-ui.com</a> or open the <a href="https://wingo-ui.com/components/card">Card docs</a>. Install: <code>npx wingo-ui@latest add card</code></p>
</blockquote>
<h3>Stacking layers</h3>
<table>
<thead>
<tr>
<th>Class</th>
<th>Value</th>
<th>Use</th>
</tr>
</thead>
<tbody><tr>
<td><code>z-dropdown</code></td>
<td>1000</td>
<td>in-flow panels that are not portaled</td>
</tr>
<tr>
<td><code>z-sticky</code></td>
<td>1100</td>
<td>sticky headers, tab bars, CTA bars</td>
</tr>
<tr>
<td><code>z-overlay</code></td>
<td>1200</td>
<td>the dimmed backdrop</td>
</tr>
<tr>
<td><code>z-modal</code></td>
<td>1300</td>
<td>dialogs, sheets, drawers</td>
</tr>
<tr>
<td><code>z-popover</code></td>
<td>1400</td>
<td>portaled menus, selects, popovers</td>
</tr>
<tr>
<td><code>z-toast</code></td>
<td>1500</td>
<td>toasts</td>
</tr>
<tr>
<td><code>z-tooltip</code></td>
<td>1600</td>
<td>tooltips, always on top</td>
</tr>
</tbody></table>
<p>Popovers sit above modals on purpose: a select inside a dialog has to open over it. No overlay in the library uses a raw number like <code>z-50</code>; small values such as <code>z-10</code> only order parts inside one component.</p>
<h3>Motion</h3>
<p>CSS transitions use three durations (<code>duration-fast</code> 150ms, <code>duration-normal</code> 240ms, <code>duration-slow</code> 400ms) and named curves (<code>ease-smooth</code> is <code>cubic-bezier(0.22, 1, 0.36, 1)</code>, <code>ease-sheet</code> is <code>cubic-bezier(0.32, 0.72, 0, 1)</code>). Our JavaScript springs are also sampled into CSS <code>linear()</code> easings: <code>ease-spring</code> with <code>duration-spring</code> (500ms) matches a spring with stiffness 300 and damping 30.</p>
<h2>Can component props work like design tokens?</h2>
<p>Yes, for the decisions that are props: the radius of every button, the default size of fields, the variant of every card. CSS variables cannot set a prop, so Wingo UI reads those from a React provider, and <code>UIProvider defaults</code> works as a token layer for props. The free <a href="https://wingo-ui.com/components/ui-config">UIProvider</a> merges app-wide defaults under the props each component receives, keyed by its <code>data-slot</code> name:</p>
<pre><code class="language-tsx">// app/providers.tsx
"use client";

import type { ReactNode } from "react";
import { ThemeProvider } from "next-themes";
import { UIProvider } from "@/lib/ui-config";

export function Providers({ children }: { children: ReactNode }) {
  return (
    &lt;ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange&gt;
      &lt;UIProvider locale="en-US" defaults={{ button: { radius: "full" }, card: { variant: "outline" } }}&gt;
        {children}
      &lt;/UIProvider&gt;
    &lt;/ThemeProvider&gt;
  );
}
</code></pre>
<p>Inside a component, the first line reads the defaults, and the signature defaults stay the last fallback:</p>
<pre><code class="language-tsx">export function Button(buttonProps: ButtonProps) {
  const { variant = "solid", tone = "neutral", size = "md", radius = "md", ...rest } = useDefaults("button", buttonProps);
  // ...
}
</code></pre>
<p>Explicit props win, then the provider, then the component's own defaults. An <code>undefined</code> prop does not override a provider value, so wrappers can pass props through without erasing the theme. The same signature takes <code>ref</code> as a regular prop, with no <code>forwardRef</code> wrapper; our <a href="https://wingo-ui.com/blog/react-19-forwardref-ref-as-prop">React 19 forwardRef migration guide</a> moves older components to that pattern, TypeScript types included.</p>
<blockquote>
<p><strong>Live demo: UIProvider.</strong> The original article embeds an interactive demo here (The first button uses its built-in defaults; the two below get radius full, size lg and tone brand from a UIProvider, and the outline one keeps the variant it passes itself). <a href="https://wingo-ui.com/blog/tailwind-v4-design-tokens">Try it live on wingo-ui.com</a> or open the <a href="https://wingo-ui.com/components/ui-config">UIProvider docs</a>. Install: <code>npx wingo-ui@latest add ui-config</code></p>
</blockquote>
<h2>How do you add these tokens to an existing Next.js project?</h2>
<p>Run <code>npx wingo-ui@latest init</code> in a project that has Tailwind CSS v4. It writes <code>wingo-ui.json</code> and <code>lib/utils.ts</code> (the <code>cn</code> helper), installs <code>clsx</code> and <code>tailwind-merge</code>, and merges the tokens into the stylesheet that contains <code>@import "tailwindcss"</code>. It only adds missing names and keeps your values; <code>add</code> later brings any tokens a new component needs. The full block, 74 variables in <code>:root</code> plus their dark values and Tailwind mappings, is served at <code>/r/tokens.css</code>, and coding agents get the same CSS from the MCP tool <code>get_theme_tokens</code> (see our <a href="https://wingo-ui.com/blog/component-library-mcp-guide">component library MCP guide</a>). The <a href="https://wingo-ui.com/docs/theming">theming docs</a> show the main tokens with their light and dark values, and the two dark mode setups the CLI supports: a <code>.dark</code> class, or <code>prefers-color-scheme</code> when your stylesheet already uses it.</p>
<p>To restyle, open the <a href="https://wingo-ui.com/theme">theme generator</a>, pick a brand color, neutrals, radius, font and component defaults, check its contrast report, and apply the export:</p>
<pre><code class="language-bash">npx wingo-ui@latest theme apply ./theme.json
</code></pre>
<p>It replaces the values of the tokens the theme sets, in light and dark, adds Tailwind mappings for new ones and prints the <code>UIProvider</code> defaults and locale to paste into your provider.</p>
<p>Coming from shadcn/ui: the shared names line up, but two keep different meanings. Our <code>--card</code> is a flat gray tile where shadcn's is the card background, and our <code>--accent</code> is an orange tint. Because <code>init</code> keeps your existing values, a shadcn project keeps its own <code>--card</code> and <code>--accent</code>, so check the soft Card variant after the merge. Our <a href="https://wingo-ui.com/blog/shadcn-alternatives">shadcn alternatives comparison</a> covers the wider differences between the two libraries.</p>
<h2>Which mistakes break a Tailwind v4 design system?</h2>
<p>The same few mistakes show up in most token files:</p>
<ul>
<li><strong><code>dark:</code> classes in components.</strong> A component that needs <code>dark:bg-zinc-900</code> is missing a token.</li>
<li><strong>Palette names in components.</strong> <code>bg-blue-600</code> cannot follow a theme; use a role or a tone.</li>
<li><strong>Fill colors as text.</strong> <code>text-destructive</code> on a dark surface fails AA; use the soft foreground.</li>
<li><strong>Per-theme values in plain <code>@theme</code>.</strong> Keep them in <code>:root</code> and <code>.dark</code>, mapped with <code>inline</code>.</li>
<li><strong>A global radius that rewrites Tailwind's scale.</strong> Every pasted <code>rounded-lg</code> changes with it.</li>
<li><strong>Raw z-index numbers.</strong> Two <code>z-50</code> overlays from two authors will collide.</li>
<li><strong>No contrast check.</strong> Tokens drift with every tweak; a check in CI catches it the same day.</li>
</ul>
<h2>Where should you start?</h2>
<p>Run <code>npx wingo-ui@latest init</code> in your Next.js project, then <code>npx wingo-ui@latest add button</code>, which brings the Tones and UIProvider items with it. Replace one hard-coded color in a real screen with a role token, open it in both themes, and add the contrast script to CI. Two more guides in <a href="https://wingo-ui.com/blog/category/nextjs-tailwind">Next.js and Tailwind CSS</a> cover the component code around the tokens: the <a href="https://wingo-ui.com/blog/react-19-forwardref-ref-as-prop">React 19 forwardRef migration</a> and an <a href="https://wingo-ui.com/blog/react-use-mobile-hook-ssr">SSR-safe React use mobile hook</a> that hydrates without a mismatch.</p>
<h2>Components in this post</h2>
<ul>
<li><a href="https://wingo-ui.com/components/button">Button</a> (Free): The button every screen starts with: five variants, six tones, three sizes plus icon sizes, and a loading state that never jumps. Install: <code>npx wingo-ui@latest add button</code></li>
<li><a href="https://wingo-ui.com/components/tones">Tones</a> (Free): One way to color a component: a tone or any CSS color becomes five CSS variables every variant paints with. Install: <code>npx wingo-ui@latest add tones</code></li>
<li><a href="https://wingo-ui.com/components/ui-config">UIProvider</a> (Free): App-wide configuration for Wingo UI: default props for every component, the Intl locale and the reduced-motion policy. Install: <code>npx wingo-ui@latest add ui-config</code></li>
<li><a href="https://wingo-ui.com/components/card">Card</a> (Free): The surface listings, stats, settings and plans sit on: media, header, body and footer parts, four variants and a lift-and-press mode. Install: <code>npx wingo-ui@latest add card</code></li>
<li><a href="https://wingo-ui.com/components/badge">Badge</a> (Free): Status chips, removable filters and animated counts in three variants and six tones, plus an anchor that pins a count or dot to any corner. Install: <code>npx wingo-ui@latest add badge</code></li>
</ul>
<h2>FAQ</h2>
<h3>Where do design tokens go in Tailwind v4?</h3>
<p>In the global CSS file, after @import "tailwindcss". Variables in an @theme block become utilities (--color-surface gives bg-surface), and values that change per theme live in :root and .dark and are mapped with @theme inline. A tailwind.config.js file is no longer required.</p>
<h3>What is the difference between @theme and @theme inline in Tailwind v4?</h3>
<p>With @theme, the utility references the theme variable, so bg-surface compiles to var(--color-surface), which is resolved once on :root. With @theme inline, the utility gets the referenced value written in, so bg-surface compiles to var(--surface) and resolves on the element that uses it. Use inline whenever a theme variable points at another variable.</p>
<h3>Should Tailwind v4 colors be OKLCH or hex?</h3>
<p>Either works, because tokens are plain CSS colors and the format does not change contrast. Tailwind's own palette moved to oklch in v4.0. OKLCH helps most when you derive colors in code, since you can keep hue and chroma and move lightness until a contrast target passes.</p>
<h3>How do I add dark mode to Tailwind v4 design tokens?</h3>
<p>Redefine the same variables in a .dark block and add @custom-variant dark (&amp;:is(.dark *)); so dark: utilities follow the class too. next-themes with attribute="class" sets the class before the first paint. Components that use semantic tokens then need no dark: classes.</p>
<h3>How do I check color contrast for design tokens?</h3>
<p>List every text token with each surface it can sit on, convert the values to sRGB, and compute the WCAG ratio for light and dark. Fail the build below 4.5:1 for body text; focus rings and other UI shapes need 3:1.</p>
<h3>Are the Wingo UI design tokens free?</h3>
<p>Yes. npx wingo-ui@latest init merges the tokens into your stylesheet without an account, and the Button, Card, Tones and UIProvider items in this guide are free. As of release 1.7.0, 75 of the 326 registry items are free and the rest need Pro.</p>
<hr />
<p><em>This article was originally published on the Wingo UI blog: <a href="https://wingo-ui.com/blog/tailwind-v4-design-tokens">Tailwind v4 Design Tokens: Colors, Dark Mode and Contrast</a>. The original has the live, interactive demos and is kept up to date.</em></p>
]]></content:encoded></item><item><title><![CDATA[Responsive Table Mobile Layout: Rows to Cards in React]]></title><description><![CDATA[When table rows should become cards below 768px, which columns survive, and how sorting and bulk actions work without headers.]]></description><link>https://wingo-ui.hashnode.dev/responsive-table-mobile-layout-rows-to-cards-in-react</link><guid isPermaLink="true">https://wingo-ui.hashnode.dev/responsive-table-mobile-layout-rows-to-cards-in-react</guid><category><![CDATA[React]]></category><category><![CDATA[CSS]]></category><category><![CDATA[Tailwind CSS]]></category><dc:creator><![CDATA[Serban Dan Rusu]]></dc:creator><pubDate>Sat, 24 Oct 2026 08:16:03 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ad6a430d0d0b34960b508b1/2e3924d9-861c-4db2-bfa0-d20145a4afe4.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2>TL;DR</h2>
<p>For a responsive table on mobile, render each row as a card below 768px: the main field as the title, one status or amount in the corner, two to four label and value pairs, and the row actions in a menu. Keep horizontal scrolling only for grids people compare column by column, like a timesheet or a pricing matrix. Switch the layouts with CSS breakpoints so the server HTML is already right, and give sorting, selection and bulk actions their own phone controls, because the headers and checkbox column are gone.</p>
<p>You ship an admin page with a seven-column table, open it on a phone, and see the client name, half a city and a horizontal scrollbar. The column headers have scrolled away by the time you reach the amount, and nobody can tell which number belongs to which row. A good responsive table mobile view is a different layout for most tables, not a narrower version of the same one. This tutorial shows when to turn rows into cards, which columns make it onto the card, how to build the switch on a plain shadcn/ui table without a hydration flash, and how sorting, selection and bulk actions keep working once the headers and the checkbox column are gone.</p>
<p>The short version of this rule is one of nine in <a href="https://wingo-ui.com/blog/mobile-first-react-components">Mobile-first React components</a>. This post is the detailed version.</p>
<h2>When should a table become cards on a phone?</h2>
<p>When people read one record at a time. A list of clients, orders, invoices or tickets is a list of records: on a phone you scan for the one you want, then open it. A card per row shows everything that matters about that record in one glance, with no sideways scrolling and no header to remember.</p>
<p>Keep the table when the job is comparing values across rows and columns. A timesheet, a pricing matrix, a monthly revenue grid or a feature comparison only makes sense when the columns line up, and cards destroy that. For those, let the table scroll sideways inside its own box and pin the first column so every row keeps its name.</p>
<table>
<thead>
<tr>
<th>What the table holds</th>
<th>Phone layout</th>
<th>Why</th>
</tr>
</thead>
<tbody><tr>
<td>Records you open (clients, orders, tickets)</td>
<td>Cards</td>
<td>One record per glance, actions in reach</td>
</tr>
<tr>
<td>Lines of one document (invoice lines, a receipt)</td>
<td>Stacked label and value lines</td>
<td>Reads like the printed document</td>
</tr>
<tr>
<td>A grid you compare (months x cities, plans x features)</td>
<td>Horizontal scroll, first column pinned</td>
<td>The columns are the point</td>
</tr>
<tr>
<td>One record's details</td>
<td>A description list, not a table</td>
<td>There is only one row</td>
</tr>
</tbody></table>
<p>The last row catches a common mistake: a "table" with two columns, Label and Value, describing one client. That is a description list. The <a href="https://wingo-ui.com/components/description-list">Description List</a> stacks label above value on phones and switches to one to three columns based on its container width, so it fits a narrow sidebar card as well as a full page.</p>
<h2>Why does horizontal scrolling fail for records?</h2>
<p>A 390px screen shows two or three columns of an admin table at once. To read one record, people scroll right, lose the name column, scroll back, and repeat for every row. The header usually scrolls out of view vertically too, so the numbers they reach have no labels. Every row costs two scroll directions.</p>
<p>Horizontal scrolling is also what you get by default. Out of the box, shadcn table mobile responsive behavior means a scroll: the shadcn/ui <code>Table</code> wraps the <code>&lt;table&gt;</code> in a <code>div</code> with <code>relative w-full overflow-x-auto</code>, so a wide table never breaks the page layout, it just scrolls inside that box. That is the right fallback, and the right layout for comparison grids, but for a client list it means the phone gets the desktop table at a third of the width.</p>
<h2>Which columns survive on a card?</h2>
<p>Five or six of them, each in a defined slot. Go through your columns and give each one a role:</p>
<ul>
<li><strong>Title</strong>: the field people recognize the record by. The client name, the order number, the ticket subject. One per card.</li>
<li><strong>Subtitle</strong>: the line that tells two similar titles apart, like the city or the email. Optional.</li>
<li><strong>Meta</strong>: one value in the top corner that people scan the list for. A status badge or an amount, not both.</li>
<li><strong>Fields</strong>: two to four label and value pairs under the title. Pick the ones people use to decide whether to open the record.</li>
<li><strong>Hidden</strong>: everything else. Internal ids, created-at dates, columns that only exist for sorting on desktop.</li>
</ul>
<p>For a client list with Name, Tax ID, City, Status, Balance, Last invoice, Owner and Created, that gives: Name as the title, City as the subtitle, Status as the meta, Balance, Last invoice and Owner as fields, and Tax ID and Created hidden. The record is still one tap away for the hidden columns.</p>
<p>Actions move into a "⋯" menu in the card's corner that opens as a bottom sheet. Inline icon buttons in a row are fine with a mouse; on a card they compete with the title for a 44px <a href="https://wingo-ui.com/blog/minimum-touch-target-size-react-tailwind">touch target</a>.</p>
<h2>How do you turn a shadcn table into cards on mobile?</h2>
<p>Render the table and a list of cards from the same data, and let CSS pick one. Here is the full component on top of the stock shadcn/ui table (<code>npx shadcn@latest add table</code>), with a plain span for the status so it needs nothing else:</p>
<pre><code class="language-tsx">// components/clients-table.tsx
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";

type Client = { id: string; name: string; city: string; status: "Active" | "Overdue"; balance: number; owner: string };

const usd = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });

function Status({ value }: { value: Client["status"] }) {
  const tone = value === "Overdue" ? "bg-destructive/10 text-destructive" : "bg-muted text-muted-foreground";
  return &lt;span className={`shrink-0 rounded-full px-2 py-0.5 text-xs font-medium ${tone}`}&gt;{value}&lt;/span&gt;;
}

export function ClientsTable({ clients }: { clients: Client[] }) {
  return (
    &lt;&gt;
      {/* desktop and tablets: the real table */}
      &lt;div className="hidden md:block"&gt;
        &lt;Table&gt;
          &lt;TableHeader&gt;
            &lt;TableRow&gt;
              &lt;TableHead&gt;Client&lt;/TableHead&gt;
              &lt;TableHead&gt;City&lt;/TableHead&gt;
              &lt;TableHead&gt;Status&lt;/TableHead&gt;
              &lt;TableHead&gt;Owner&lt;/TableHead&gt;
              &lt;TableHead className="text-right"&gt;Balance&lt;/TableHead&gt;
            &lt;/TableRow&gt;
          &lt;/TableHeader&gt;
          &lt;TableBody&gt;
            {clients.map((c) =&gt; (
              &lt;TableRow key={c.id}&gt;
                &lt;TableCell className="font-medium"&gt;{c.name}&lt;/TableCell&gt;
                &lt;TableCell&gt;{c.city}&lt;/TableCell&gt;
                &lt;TableCell&gt;
                  &lt;Status value={c.status} /&gt;
                &lt;/TableCell&gt;
                &lt;TableCell&gt;{c.owner}&lt;/TableCell&gt;
                &lt;TableCell className="text-right tabular-nums"&gt;{usd.format(c.balance)}&lt;/TableCell&gt;
              &lt;/TableRow&gt;
            ))}
          &lt;/TableBody&gt;
        &lt;/Table&gt;
      &lt;/div&gt;

      {/* phones: one card per row */}
      &lt;ul aria-label="Clients" className="flex flex-col gap-2 md:hidden"&gt;
        {clients.map((c) =&gt; (
          &lt;li key={c.id} className="rounded-2xl border p-4"&gt;
            &lt;div className="flex items-start justify-between gap-3"&gt;
              &lt;div className="min-w-0"&gt;
                &lt;p className="font-semibold"&gt;{c.name}&lt;/p&gt;
                &lt;p className="truncate text-sm text-muted-foreground"&gt;{c.city}&lt;/p&gt;
              &lt;/div&gt;
              &lt;Status value={c.status} /&gt;
            &lt;/div&gt;
            &lt;dl className="mt-3 grid grid-cols-2 gap-x-4 gap-y-2 text-sm"&gt;
              &lt;div&gt;
                &lt;dt className="text-xs text-muted-foreground"&gt;Balance&lt;/dt&gt;
                &lt;dd className="tabular-nums"&gt;{usd.format(c.balance)}&lt;/dd&gt;
              &lt;/div&gt;
              &lt;div&gt;
                &lt;dt className="text-xs text-muted-foreground"&gt;Owner&lt;/dt&gt;
                &lt;dd&gt;{c.owner}&lt;/dd&gt;
              &lt;/div&gt;
            &lt;/dl&gt;
          &lt;/li&gt;
        ))}
      &lt;/ul&gt;
    &lt;/&gt;
  );
}
</code></pre>
<p>That is the whole table to cards on mobile pattern. Three details matter more than the styling.</p>
<p>The switch is a CSS breakpoint. <code>hidden md:block</code> and <code>md:hidden</code> are both in the server HTML, so a phone paints the cards on first load. If you switch with a <code>useIsMobile()</code> hook instead, the server renders the table (the hook is <code>false</code> there), the phone shows it, and the cards replace it after hydration. The <a href="https://wingo-ui.com/blog/react-use-mobile-hook-ssr">use mobile hook guide</a> covers why that happens and where a hook is still the right tool.</p>
<p>The cards are a real list. <code>&lt;ul&gt;</code> with an <code>aria-label</code> tells a screen reader how many records there are, and <code>&lt;dt&gt;</code> / <code>&lt;dd&gt;</code> pairs keep each value attached to its label. Hiding the table with <code>display: none</code> also removes it from the accessibility tree, so screen reader users on a phone get the same cards as everyone else, not a hidden table.</p>
<p>Both copies render, so the DOM holds every row twice. For 25 rows a page that is nothing. For hundreds, paginate or virtualize, or render the hidden layout lazily. Past 200 rows, the Wingo UI Data Table virtualizes the visible layout and renders only a first screen of 30 rows in the hidden one.</p>
<h3>What about the CSS-only data-label trick?</h3>
<p>The popular alternative keeps one <code>&lt;table&gt;</code>, sets <code>display: block</code> on rows and cells below a breakpoint, and prints each header through <code>td::before { content: attr(data-label) }</code>. It saves the second copy of the markup, but it has two costs. Changing <code>display</code> on table elements used to strip their table semantics: Adrian Roselli's long-running test post records that Safari dropped them for every display value until Safari 17 fixed it, and as of his October 2023 update <code>display: contents</code> is the one value that may still cause issues (<a href="https://adrianroselli.com/2018/02/tables-css-display-properties-and-aria.html">Tables, CSS Display Properties, and ARIA</a>). And a screen reader still announces a table with seven columns while the screen shows a stack of cards, with the labels living in CSS.</p>
<p>If you use it, test with VoiceOver on an iPhone and TalkBack on Android. If you cannot test, the two-layout version above is the safer default.</p>
<h2>How do sorting and bulk actions work without headers?</h2>
<p>They need their own controls, because the things people pressed on desktop are gone. Plan each one:</p>
<ul>
<li><strong>Sort</strong>: a row of "Sort by" chips above the cards, one per sortable column, tap to cycle the direction. Or, with many sortable columns, a Sort button that opens a bottom sheet listing "Balance, descending", "Client, ascending" and so on.</li>
<li><strong>Filters</strong>: the same filter UI as desktop, opened in a bottom sheet instead of a popover.</li>
<li><strong>Selection</strong>: no checkbox column. Long press a card to start selecting (the way Gmail and Photos do it on Android), then each tap toggles a card, and a checkbox appears on every card while selection mode is on.</li>
<li><strong>Bulk actions</strong>: a bar that floats at the bottom of the screen while rows are selected, padded for the home indicator, instead of a toolbar band at the top that has scrolled away.</li>
<li><strong>Paging</strong>: Load more at the end of the list. Numbered pages with 32px page buttons are hard to hit and break the scroll flow.</li>
</ul>
<p>Long press is invisible, so it cannot be the only way in. Keep the "⋯" menu visible on every card, and if selection matters, add a visible Select action to the toolbar or the row menu as well.</p>
<h2>How do you get the same layout from Wingo UI?</h2>
<p>Use the <a href="https://wingo-ui.com/components/table">Table</a> for small read-only tables and the <a href="https://wingo-ui.com/components/data-table">Data Table</a> for admin lists. Both make a React table mobile responsive without a second markup branch: they render the desktop table and the phone layout in the server HTML and switch at 768px with CSS, and both assign card roles from your columns: the first column is the title, the last numeric column (in the Data Table, the first badge column before that) is the meta, and the rest are fields until you say otherwise with <code>mobile</code>.</p>
<p>Here is an orders table with the roles spelled out:</p>
<pre><code class="language-tsx">// components/recent-orders.tsx
"use client";

import { Table, type TableColumn } from "@/components/ui/table";

type Order = { id: string; customer: string; city: string; placed: string; items: number; total: number };

const columns: TableColumn&lt;Order&gt;[] = [
  { key: "customer", header: "Customer", sortable: true, mobile: "title" },
  { key: "city", header: "City", mobile: "subtitle" },
  { key: "id", header: "Order", mobile: "hidden" },
  { key: "placed", header: "Placed", type: "date", sortable: true },
  { key: "items", header: "Items", type: "number" },
  { key: "total", header: "Total", type: "currency", sortable: true, mobile: "meta" },
];

export function RecentOrders({ orders }: { orders: Order[] }) {
  return &lt;Table caption="Recent orders" columns={columns} data={orders} locale="en-US" onRowClick={(order) =&gt; console.log(order.id)} /&gt;;
}
</code></pre>
<p>On a phone each order is a card with the customer as the title, the city under it, the total in the corner, and Placed and Items as fields. The order number stays on desktop only. Columns past the fourth field move behind a "Show more" toggle on the card. Because <code>customer</code>, <code>placed</code> and <code>total</code> are sortable, a row of Sort by chips appears above the cards, and <code>onRowClick</code> makes each card a single button that keyboard users reach with Tab.</p>
<blockquote>
<p><strong>Live demo: Table.</strong> The original article embeds an interactive demo here (Below 768px each invoice line becomes a card with the amount in the corner and Sort by chips above; on a wide screen it is a regular table with sortable headers and a totals row). <a href="https://wingo-ui.com/blog/responsive-table-mobile-cards">Try it live on wingo-ui.com</a> or open the <a href="https://wingo-ui.com/components/table">Table docs</a>. Install: <code>npx wingo-ui@latest add table</code></p>
</blockquote>
<p>The demo is an invoice with five lines and totals. Narrow your window, or open the page on a phone, to see the cards and the separate totals card. For a receipt-like look, <code>mobileLayout="stack"</code> lists label and value lines without card chrome, and <code>mobileLayout="scroll"</code> keeps the table scrolling sideways with the first column pinned, for comparison grids.</p>
<h3>How does the Data Table handle selection on a phone?</h3>
<p>The way the previous section describes. With <code>selectionMode="multiple"</code>, a long press of about 450ms selects a card, then taps add or remove cards, and the bulk actions you return from <code>bulkActions</code> float at the bottom. The sort button in the toolbar opens a bottom sheet of "Balance, descending" style options, filters and the row menu open as bottom sheets too, and <code>mobilePagination="load-more"</code> (the default) appends rows.</p>
<pre><code class="language-tsx">// components/clients.tsx
"use client";

import { Archive, Mail, Pencil } from "lucide-react";
import { DataTable, createColumns } from "@/components/ui/data-table";

type Client = { id: string; name: string; city: string; status: string; balance: number; owner: string; taxId: string };

const col = createColumns&lt;Client&gt;();
const columns = col([
  col.accessor("name", { header: "Client" }),
  col.accessor("city", { header: "City", mobile: "subtitle", filter: "multi-select" }),
  col.accessor("status", {
    header: "Status",
    type: "badge",
    badge: { Active: { tone: "success" }, Overdue: { tone: "danger" } },
    filter: "multi-select",
  }),
  col.accessor("balance", { header: "Balance", type: "currency" }),
  col.accessor("owner", { header: "Owner" }),
  col.accessor("taxId", { header: "Tax ID", mobile: "hidden" }),
]);

export function Clients({ clients }: { clients: Client[] }) {
  return (
    &lt;DataTable
      data={clients}
      columns={columns}
      caption="Clients"
      locale="en-US"
      selectionMode="multiple"
      bulkActions={(rows) =&gt; [
        { label: "Send email", icon: &lt;Mail /&gt;, onSelect: () =&gt; console.log(rows.length) },
        { label: "Archive", icon: &lt;Archive /&gt; },
      ]}
      rowActions={(row) =&gt; [{ label: "Edit", icon: &lt;Pencil /&gt;, onSelect: () =&gt; console.log(row.id) }]}
    /&gt;
  );
}
</code></pre>
<p>Here the defaults do the rest: Client becomes the title because it is first, and Status becomes the meta because it is the first badge column. Balance and Owner are the fields. The Data Table shows at most four fields and drops the others from the card, so put the important ones first or mark the rest <code>hidden</code> on purpose.</p>
<blockquote>
<p><strong>Live demo: Data Table.</strong> The original article embeds an interactive demo here (On a phone, long press a client card to start selecting, then tap more cards: the bulk bar floats at the bottom. On a wide screen it is the full table). <a href="https://wingo-ui.com/blog/responsive-table-mobile-cards">Try it live on wingo-ui.com</a> or open the <a href="https://wingo-ui.com/components/data-table">Data Table docs</a>. Install: <code>npx wingo-ui@latest add data-table</code></p>
</blockquote>
<p>When the column mapping is not enough, both components take a <code>renderCard</code> function for a card of your own, and <code>mobileLayout="scroll"</code> opts a single table out of cards.</p>
<h2>What if the rows are simpler than a table?</h2>
<p>Then skip the table on every screen size. A list of team members, notifications or settings with a name, a line of detail and one value is a list, and the <a href="https://wingo-ui.com/components/list">List</a> gives it rows with an avatar or icon, a trailing value, a row menu, and swipe actions on touch (swipe left to archive or delete, with a long press sheet as the visible alternative). It reads well at 390px and at 1440px without a breakpoint.</p>
<h2>How do you test a responsive table on mobile?</h2>
<p>Check the one thing that breaks most: the page should never scroll sideways at phone width. A Playwright test catches it in CI:</p>
<pre><code class="language-ts">// e2e/no-horizontal-scroll.spec.ts
import { test, expect } from "@playwright/test";

test.use({ viewport: { width: 390, height: 844 } });

test("the clients page fits a phone", async ({ page }) =&gt; {
  await page.goto("/clients");
  const overflow = await page.evaluate(() =&gt; document.documentElement.scrollWidth - window.innerWidth);
  expect(overflow).toBeLessThanOrEqual(0);
  await expect(page.getByRole("list", { name: "Clients" })).toBeVisible();
  await expect(page.getByRole("table")).toBeHidden();
});
</code></pre>
<p>Add a 360px case for small Android phones, and run it in light and dark. Then do one pass on a real phone: long press a card, open the row menu and the sort control, and check that the bulk bar clears the home indicator.</p>
<h2>Where should you start?</h2>
<p>Take your busiest admin table, give each column a role on paper, and build the card layout next to the table with <code>md:hidden</code>. If you would rather start from components that already do the mapping, the sort chips, the long press selection and the floating bulk bar, the Table and the Data Table are part of <a href="https://wingo-ui.com/pricing">Wingo UI Pro</a>; add one with <code>npx wingo-ui@latest add data-table</code>. For the step by step build of filters and server pagination on TanStack, read the <a href="https://wingo-ui.com/blog/tanstack-table-v9-data-table">TanStack Table v9 data table tutorial</a>, and find more phone layouts in <a href="https://wingo-ui.com/blog/category/mobile-ui">Mobile UI in React</a>.</p>
<h2>Components in this post</h2>
<ul>
<li><a href="https://wingo-ui.com/components/data-table">Data Table</a> (Pro): The admin table on TanStack Table v9: search, filters, sorting, bulk actions, pinning, resizing, virtualization and cards on phones. Install: <code>npx wingo-ui@latest add data-table</code></li>
<li><a href="https://wingo-ui.com/components/table">Table</a> (Pro): The library's one table look: semantic table parts plus a props API for small read-only tables that turn into cards on phones. Install: <code>npx wingo-ui@latest add table</code></li>
<li><a href="https://wingo-ui.com/components/list">List</a> (Pro): Rows on a page for settings, contacts, inboxes and orders: iOS grouped panels, selection, row menus, swipe actions and drag to reorder. Install: <code>npx wingo-ui@latest add list</code></li>
<li><a href="https://wingo-ui.com/components/description-list">Description List</a> (Pro): The key-value grid of every detail page: client details, invoice summaries and profiles, with copy buttons, skeletons and iOS rows. Install: <code>npx wingo-ui@latest add description-list</code></li>
</ul>
<h2>FAQ</h2>
<h3>How do I make a table responsive on mobile?</h3>
<p>Decide first whether people read records or compare columns. For records, render a card per row below 768px with the main field as the title and two to four labeled fields; for comparison grids, keep the table and let it scroll sideways inside its box with the first column pinned.</p>
<h3>Is the shadcn table responsive on mobile?</h3>
<p>It scrolls. The shadcn/ui Table wraps the table in a div with overflow-x-auto, so a wide table scrolls sideways inside its container. Turning rows into cards is up to you, usually with a second markup branch hidden and shown with md: classes.</p>
<h3>Should I use display: block on table rows to make cards?</h3>
<p>It works visually and keeps one copy of the markup, but changing display on table elements removed table semantics in Safari until Safari 17 (Adrian Roselli, October 2023), and display: contents can still cause problems. A separate list of cards with real dt and dd labels is easier to get right for screen readers.</p>
<h3>How do users sort a table on a phone without column headers?</h3>
<p>Give them a control that does what the headers did: a row of Sort by chips above the cards, or a Sort button that opens a bottom sheet listing each column ascending and descending.</p>
<h3>Should I use useIsMobile to switch between the table and the cards?</h3>
<p>No. A media query hook is false on the server, so phones render the table first and swap to cards after hydration. Render both and hide one with md:hidden and hidden md:block, which is correct in the first paint.</p>
<hr />
<p><em>This article was originally published on the Wingo UI blog: <a href="https://wingo-ui.com/blog/responsive-table-mobile-cards">Responsive Table Mobile Layout: Rows to Cards in React</a>. The original has the live, interactive demos and is kept up to date.</em></p>
]]></content:encoded></item><item><title><![CDATA[React Bottom Sheet for the Web: Snap Points, Drag, Keyboard]]></title><description><![CDATA[A React bottom sheet for the web on vaul: snap points, drag to dismiss, nested sheets, the on-screen keyboard and the home indicator, with code.]]></description><link>https://wingo-ui.hashnode.dev/react-bottom-sheet-for-the-web-snap-points-drag-keyboard</link><guid isPermaLink="true">https://wingo-ui.hashnode.dev/react-bottom-sheet-for-the-web-snap-points-drag-keyboard</guid><category><![CDATA[React]]></category><category><![CDATA[Mobile Development]]></category><category><![CDATA[UX]]></category><dc:creator><![CDATA[Serban Dan Rusu]]></dc:creator><pubDate>Thu, 22 Oct 2026 08:20:11 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ad6a430d0d0b34960b508b1/3f223134-dea4-43fe-b1a0-82a9a9056248.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2>TL;DR</h2>
<p>A React bottom sheet for the web is a draggable dialog pinned to the bottom of the screen, so build it on a DOM dialog primitive: Base UI's Drawer (maintained) or vaul (unmaintained per its README, still the most complete option on Radix). Pass snap points as fractions from 0 to 1 in a stable array, let the body scroll only at the last point, raise the sheet above the on-screen keyboard with the visualViewport API, and pad the part that touches the bottom edge with env(safe-area-inset-bottom). The free Wingo UI Drawer does all of this on top of vaul 1.1.2.</p>
<p>Search for a React bottom sheet and many of the top results are React Native packages such as @gorhom/bottom-sheet, which reach the browser only through React Native Web. This guide is about the React bottom sheet web apps need: a real dialog in the DOM, pointer events, <code>position: fixed</code>, an on-screen keyboard that covers half the screen and a home indicator under your Save button. It covers the five parts that decide whether a sheet feels native on a phone (snap points, drag to dismiss, nested sheets, the keyboard and the safe area), with code for vaul and for the free Wingo UI <a href="https://wingo-ui.com/components/drawer">Drawer</a>, which is built on it.</p>
<h2>Which React bottom sheet library works on the web?</h2>
<p>For apps that render to the DOM, vaul and Base UI's Drawer are the two serious choices, because both sit on a dialog primitive: focus trapping, Escape and the accessible title come with them. As of October 2026, from the npm registry and each project's docs:</p>
<table>
<thead>
<tr>
<th>Library</th>
<th>Built on</th>
<th>Snap points</th>
<th>Nested sheets</th>
<th>Latest release</th>
</tr>
</thead>
<tbody><tr>
<td><a href="https://github.com/emilkowalski/vaul">vaul</a></td>
<td>Radix Dialog</td>
<td>Fractions or px strings</td>
<td><code>Drawer.NestedRoot</code></td>
<td>1.1.2, Dec 14, 2024</td>
</tr>
<tr>
<td><a href="https://base-ui.com/react/components/drawer">Base UI Drawer</a></td>
<td>Base UI Dialog</td>
<td>Fractions, px or rem</td>
<td>Yes, styled with CSS variables</td>
<td>@base-ui/react 1.8.0, Sep 4, 2026</td>
</tr>
<tr>
<td><a href="https://github.com/temzasse/react-modal-sheet">react-modal-sheet</a></td>
<td>Motion</td>
<td>Fractions or px, including 0 (closed) and 1</td>
<td>Not documented</td>
<td>5.6.0, Mar 27, 2026</td>
</tr>
<tr>
<td><a href="https://github.com/stipsan/react-spring-bottom-sheet">react-spring-bottom-sheet</a></td>
<td>react-spring, react-use-gesture</td>
<td>A function of the content's min and max height</td>
<td>Not documented</td>
<td>3.4.1, Jun 1, 2022</td>
</tr>
</tbody></table>
<p>Three facts the table cannot show change the decision. Vaul's README says "This repo is unmaintained", and no release has shipped since December 2024; its peer range still includes React 19, and the Wingo UI Drawer runs on it with React 19.3. react-spring-bottom-sheet's peer range stops at React 18. react-modal-sheet's README says it "doesn't include any built-in accessibility properties", so focus trapping and dialog semantics are yours to add.</p>
<p>If your project already uses Base UI, the default for new shadcn/ui projects since <a href="https://ui.shadcn.com/docs/changelog/2026-07-base-ui-default">July 2026</a>, use its Drawer: it is maintained and covers snap points, nesting and the keyboard (<code>Drawer.VirtualKeyboardProvider</code>). On Radix, vaul is still the most complete option, and the rest of this guide is about the gaps you cover around it.</p>
<h2>How do vaul drawer snap points work?</h2>
<p>Vaul drawer snap points are stops on the way up, listed from least to most visible: a number is a fraction of the screen height (<code>0.45</code> is 45%), a string is pixels (<code>"320px"</code>). The sheet renders at full height and is translated down to the active point. Three details trip people up in vaul 1.1.2:</p>
<ol>
<li><strong>Fractions from 0 to 1.</strong> The type comment says "Array of numbers from 0 to 100", but the code multiplies each number by the window height, so <code>50</code> means fifty screens. Write <code>0.5</code>.</li>
<li><strong>Pixels only.</strong> A string goes through <code>parseInt</code>, so <code>"30rem"</code> becomes 30 pixels.</li>
<li><strong>Hoist the array.</strong> Vaul re-runs its snap effect whenever <code>snapPoints</code> changes identity, so an inline literal re-snaps the sheet on every parent render.</li>
</ol>
<p>Then decide what scrolls. Below the last point the body should stay still, so an upward swipe drags the sheet instead of scrolling a list the user can only half see. <code>fadeFromIndex</code> picks the point where the overlay starts to dim; it defaults to the last one, so a half-open sheet leaves the page bright.</p>
<p>Make the React bottom sheet modal when it holds a task. Pass <code>modal={false}</code> when the page behind must stay usable, like a results list over a map:</p>
<pre><code class="language-tsx">"use client";

import { useState } from "react";
import { Drawer, type DrawerSnapPoint } from "@/components/ui/drawer";

// fractions of the screen height, least visible first; one array for the app's lifetime
const SNAP_POINTS: DrawerSnapPoint[] = [0.45, 1];

type Apartment = { id: string; title: string; rent: string };

export function MapResults({ apartments }: { apartments: Apartment[] }) {
  const [snap, setSnap] = useState&lt;DrawerSnapPoint | null&gt;(SNAP_POINTS[0]);
  return (
    &lt;Drawer
      defaultOpen
      modal={false}
      dismissible={false}
      snapPoints={SNAP_POINTS}
      activeSnapPoint={snap}
      onActiveSnapPointChange={setSnap}
      title="Apartments in Chicago"
      description={`${apartments.length} results in the map area`}
    &gt;
      &lt;ul className="flex flex-col gap-3"&gt;
        {apartments.map((a) =&gt; (
          &lt;li key={a.id} className="flex justify-between gap-3 text-[15px]"&gt;
            &lt;span className="truncate"&gt;{a.title}&lt;/span&gt;
            &lt;span className="font-semibold tabular-nums"&gt;{a.rent}&lt;/span&gt;
          &lt;/li&gt;
        ))}
      &lt;/ul&gt;
    &lt;/Drawer&gt;
  );
}
</code></pre>
<p>With snap points, <code>dismissible={false}</code> stops the sheet at its first point, so the list can never be dragged away behind the map. The Drawer keeps one array per distinct list of points even when you pass a literal, lets the body scroll only at the last point, and steps to the next point on a tap on the handle.</p>
<h2>How does drag to dismiss decide when to close?</h2>
<p>By distance or speed, measured when the finger lifts. Without snap points, vaul closes the sheet when you let go past 25% of its height (<code>closeThreshold</code>, default 0.25) or flick it faster than 0.4 pixels per millisecond; anything less eases back over 500ms on <code>cubic-bezier(0.32, 0.72, 0, 1)</code>. With snap points it settles on the nearest point, a flick moves one point in its direction, and a flick faster than 2 pixels per millisecond closes the sheet or opens it fully. The harder part is when a drag should not start at all. Vaul 1.1.2 refuses one:</p>
<ul>
<li>from inside a scrollable element that is not at its top, so a long list scrolls first;</li>
<li>for a moment after the content scrolled (<code>scrollLockTimeout</code>: 100ms in the code, although the type comment says 500ms);</li>
<li>while text is selected, from a native <code>&lt;select&gt;</code>, or from anything inside <code>[data-vaul-no-drag]</code>;</li>
<li>in the first 500ms after opening.</li>
</ul>
<p>Left and right drawers skip the scroll and timing checks. For content with its own horizontal gestures (a carousel, a map, a range slider) set <code>handleOnly</code>, so only the handle drags. And give every drag a visible alternative: Escape, a tap on the dimmed page and a close button. The Wingo UI Drawer leaves the close button off bottom sheets by default, so pass <code>showClose</code> (panels and dialogs always have one).</p>
<p>The demo below is a listings page. Filters opens a sheet with price, condition and category filters and a pinned button with the result count.</p>
<blockquote>
<p><strong>Live demo: Drawer.</strong> The original article embeds an interactive demo here (Tap Filters, then drag the sheet down: let go past a quarter of its height or flick it and it closes, a shorter drag settles back. Scroll the categories first: the sheet only drags once the list is back at the top). <a href="https://wingo-ui.com/blog/react-bottom-sheet-for-the-web">Try it live on wingo-ui.com</a> or open the <a href="https://wingo-ui.com/components/drawer">Drawer docs</a>. Install: <code>npx wingo-ui@latest add drawer</code></p>
</blockquote>
<h2>How do you nest bottom sheets?</h2>
<p>Open the second sheet from inside the first and push the first one back, like iOS. In vaul that is <code>Drawer.NestedRoot</code>, rendered inside the parent's <code>Drawer.Content</code>; it throws when it has no parent drawer, and as the child opens it shrinks the parent 16px narrower, lifts it 16px and follows the child's drag. It moves the parent only from its own <code>onOpenChange</code>, so a child opened by setting <code>open</code> from state leaves the parent flat.</p>
<p>The Wingo UI Drawer detects nesting from context. A drawer rendered inside another drawer's content uses the nested root on its own and pushes the parent for controlled opens too:</p>
<pre><code class="language-tsx">"use client";

import type { ReactNode } from "react";
import { Button } from "@/components/ui/button";
import { Drawer, DrawerBody, DrawerClose, DrawerContent, DrawerFooter, DrawerHeader, DrawerRoot, DrawerTrigger } from "@/components/ui/drawer";

export function DeliveryAddress({ addresses, newAddressForm }: { addresses: ReactNode; newAddressForm: ReactNode }) {
  return (
    &lt;DrawerRoot&gt;
      &lt;DrawerTrigger asChild&gt;
        &lt;Button variant="soft" size="sm"&gt;Change&lt;/Button&gt;
      &lt;/DrawerTrigger&gt;
      &lt;DrawerContent&gt;
        &lt;DrawerHeader title="Delivery address" description="Arrives in 1-2 business days" /&gt;
        &lt;DrawerBody&gt;
          {addresses}
          {/* inside the first sheet's content, so it nests and pushes the first one back */}
          &lt;Drawer
            title="New address"
            trigger={&lt;Button variant="ghost" fullWidth&gt;Add a new address&lt;/Button&gt;}
            footer={
              &lt;DrawerClose asChild&gt;
                &lt;Button size="lg" fullWidth&gt;Save address&lt;/Button&gt;
              &lt;/DrawerClose&gt;
            }
          &gt;
            {newAddressForm}
          &lt;/Drawer&gt;
        &lt;/DrawerBody&gt;
        &lt;DrawerFooter&gt;
          &lt;DrawerClose asChild&gt;
            &lt;Button size="lg" fullWidth&gt;Deliver here&lt;/Button&gt;
          &lt;/DrawerClose&gt;
        &lt;/DrawerFooter&gt;
      &lt;/DrawerContent&gt;
    &lt;/DrawerRoot&gt;
  );
}
</code></pre>
<p>Stop at two levels. On a 390px screen a third sheet is a page, so navigate to one.</p>
<h2>What happens when the keyboard opens inside a bottom sheet?</h2>
<p>The keyboard covers the sheet unless something moves it. On Safari for iOS, and on Chrome for Android since version 108, the keyboard resizes only the visual viewport (<a href="https://developer.chrome.com/blog/viewport-resize-behavior">Chrome's announcement</a>), so an element at <code>position: fixed; bottom: 0</code> stays behind it. Vaul's <code>repositionInputs</code>, on by default, listens to <code>visualViewport</code> resize events: while a field has focus it raises the sheet by the height the keyboard covers and shrinks it when it no longer fits above the keyboard.</p>
<p>Two rules make that work in practice:</p>
<ul>
<li><strong>Do not focus a field on open.</strong> The Wingo UI Drawer focuses the sheet itself, so the keyboard does not cover half the sheet before anyone has read it; Tab moves on to the first control. When typing is the whole task, such as renaming a list, pass <code>onOpenAutoFocus</code>, call <code>event.preventDefault()</code> and focus the field yourself.</li>
<li><strong>Use 16px text in inputs</strong> (<code>text-base md:text-sm</code>). iOS Safari zooms into a focused field with smaller text.</li>
</ul>
<p>Turn <code>repositionInputs</code> off when the sheet handles the keyboard itself, for example a chat composer that follows the keyboard with the Pro <a href="https://wingo-ui.com/components/use-keyboard-inset">useKeyboardInset</a> hook.</p>
<h2>How do you keep a bottom sheet clear of the home indicator?</h2>
<p>Let the page reach the screen edges with <code>viewport-fit=cover</code> (in Next.js, <code>viewportFit: "cover"</code> in the root layout's <code>viewport</code> export), then pad the part of the sheet that touches the bottom edge with <code>max(env(safe-area-inset-bottom), 16px)</code>. The <code>max()</code> keeps 16px of air on devices that report an inset of 0. Pad only the last part: the footer when there is one, otherwise the scrolling body, so its last row can scroll above the indicator. Cap the height at <code>92dvh</code> so a strip of the page stays visible to tap. On a plain vaul drawer:</p>
<pre><code class="language-tsx">"use client";

import type { ReactNode } from "react";
import { Drawer } from "vaul";

type SheetProps = { trigger: ReactNode; title: string; children: ReactNode; actions: ReactNode };

export function BottomSheet({ trigger, title, children, actions }: SheetProps) {
  return (
    &lt;Drawer.Root&gt;
      &lt;Drawer.Trigger asChild&gt;{trigger}&lt;/Drawer.Trigger&gt;
      &lt;Drawer.Portal&gt;
        &lt;Drawer.Overlay className="fixed inset-0 z-40 bg-black/40" /&gt;
        &lt;Drawer.Content
          aria-describedby={undefined}
          className="fixed inset-x-0 bottom-0 z-50 flex max-h-[92dvh] flex-col rounded-t-[28px] bg-popover outline-none"
        &gt;
          &lt;Drawer.Handle className="mt-2.5" /&gt;
          &lt;Drawer.Title className="px-5 pt-3 pb-2 text-[17px] font-semibold"&gt;{title}&lt;/Drawer.Title&gt;
          &lt;div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-5"&gt;{children}&lt;/div&gt;
          {/* the part that touches the bottom edge pads the home indicator */}
          &lt;div className="flex flex-col gap-2 px-5 pt-3 pb-[max(env(safe-area-inset-bottom),16px)]"&gt;{actions}&lt;/div&gt;
        &lt;/Drawer.Content&gt;
      &lt;/Drawer.Portal&gt;
    &lt;/Drawer.Root&gt;
  );
}
</code></pre>
<p>The Wingo UI Drawer pads whichever part comes last. Its <code>variant="floating"</code> instead sits 8px above the safe area with every corner rounded, the look the <a href="https://wingo-ui.com/components/action-sheet">Action Sheet</a> uses for its iOS variant. The same insets for tab bars, FABs and toasts are in our <a href="https://wingo-ui.com/blog/tailwind-safe-area-insets">Tailwind safe area insets tutorial</a>.</p>
<h2>Which bottom sheet should you use for each job?</h2>
<p>There is no single bottom sheet UI React apps should use for everything. Pick by the job:</p>
<table>
<thead>
<tr>
<th>Component</th>
<th>Use it for</th>
<th>Built on</th>
<th>Access</th>
</tr>
</thead>
<tbody><tr>
<td><a href="https://wingo-ui.com/components/drawer">Drawer</a></td>
<td>Forms, filters, pickers, results over a map; a side panel or a dialog from 768px (<code>desktop</code>)</td>
<td>vaul</td>
<td>Free</td>
</tr>
<tr>
<td><a href="https://wingo-ui.com/components/action-sheet">Action Sheet</a></td>
<td>A short list of actions about one thing, with a separate Cancel and <code>await show()</code></td>
<td>Drawer</td>
<td>Pro</td>
</tr>
<tr>
<td><a href="https://wingo-ui.com/components/family-drawer">Family Drawer</a></td>
<td>A floating sheet that morphs between views, such as a menu and a confirmation</td>
<td>vaul</td>
<td>Free</td>
</tr>
<tr>
<td><a href="https://wingo-ui.com/components/sheet">Sheet</a></td>
<td>A side panel on desktop that becomes a bottom sheet on phones (<code>mobileSide="bottom"</code>)</td>
<td>Radix Dialog and motion</td>
<td>Free</td>
</tr>
</tbody></table>
<p>The Action Sheet also works imperatively: mount an <code>ActionSheetProvider</code> once, take <code>const actionSheet = useActionSheet()</code> in a component, and in a click handler <code>await actionSheet.show({ actions })</code> resolves with the chosen action's id, or <code>null</code> on Cancel or a dismiss. An action whose <code>onSelect</code> returns a promise keeps the sheet open with a spinner in its row; the sheet closes when the promise resolves, and the row shakes and stays ready to retry when it rejects.</p>
<blockquote>
<p><strong>Live demo: Action Sheet.</strong> The original article embeds an interactive demo here (Tap the ⋯ button on the listing: the iOS sheet floats above the home indicator with Cancel in its own card. Delete listing shows a spinner in its row until its promise resolves). <a href="https://wingo-ui.com/blog/react-bottom-sheet-for-the-web">Try it live on wingo-ui.com</a> or open the <a href="https://wingo-ui.com/components/action-sheet">Action Sheet docs</a>. Install: <code>npx wingo-ui@latest add action-sheet</code></p>
</blockquote>
<p>The Family Drawer keeps one floating sheet and animates its height between views, which suits a short flow like a menu that leads to a confirmation.</p>
<blockquote>
<p><strong>Live demo: Family Drawer.</strong> The original article embeds an interactive demo here (Tap Try it out and pick an option: the floating sheet morphs to the height of the new view. Drag it down or tap outside to close it). <a href="https://wingo-ui.com/blog/react-bottom-sheet-for-the-web">Try it live on wingo-ui.com</a> or open the <a href="https://wingo-ui.com/components/family-drawer">Family Drawer docs</a>. Install: <code>npx wingo-ui@latest add family-drawer</code></p>
</blockquote>
<p>In the registry (release 1.7.0), 39 other items import the Drawer, including the Popover, Select, Dropdown Menu and Dialog, which open as bottom sheets below 768px. Every sheet in an app drags, snaps and pads the same way.</p>
<h2>What should you do next?</h2>
<p>Install the free Drawer with <code>npx wingo-ui@latest add drawer</code>, open your app at 390px and drag a sheet closed with its list scrolled halfway. For the switch between a centered card and a sheet, read the <a href="https://wingo-ui.com/blog/shadcn-responsive-dialog-on-mobile">shadcn responsive dialog tutorial</a>; for touch targets, safe areas and the rest of the phone rules, the <a href="https://wingo-ui.com/blog/mobile-first-react-components">mobile-first React components guide</a> and the <a href="https://wingo-ui.com/blog/category/mobile-ui">Mobile UI in React</a> category. The Action Sheet is part of <a href="https://wingo-ui.com/pricing">Wingo UI Pro</a>.</p>
<h2>Components in this post</h2>
<ul>
<li><a href="https://wingo-ui.com/components/drawer">Drawer</a> (Free): The library's bottom sheet on vaul: drag to dismiss, snap points, nesting, and a side panel or dialog on desktop. Install: <code>npx wingo-ui@latest add drawer</code></li>
<li><a href="https://wingo-ui.com/components/family-drawer">Family Drawer</a> (Free): A floating bottom drawer that morphs smoothly between stacked views, inspired by the Family app. Install: <code>npx wingo-ui@latest add family-drawer</code></li>
<li><a href="https://wingo-ui.com/components/action-sheet">Action Sheet</a> (Pro): The iOS action sheet: a short list of actions about one thing, with a title, red destructive rows and a separate Cancel, plus await show(). Install: <code>npx wingo-ui@latest add action-sheet</code></li>
<li><a href="https://wingo-ui.com/components/sheet">Sheet</a> (Free): A panel that slides in from any edge on a spring: filters, a cart, an inspector or the phone navigation menu, swipeable to close on touch. Install: <code>npx wingo-ui@latest add sheet</code></li>
</ul>
<h2>FAQ</h2>
<h3>Can I use @gorhom/bottom-sheet in a React web app?</h3>
<p>Only through React Native Web: it is a React Native library that needs Reanimated and Gesture Handler. In a Next.js or Vite app that renders to the DOM, use vaul or Base UI's Drawer, which are built on accessible dialog primitives.</p>
<h3>Is vaul still maintained?</h3>
<p>No. As of October 2026 its README says the repo is unmaintained, and the last release is 1.1.2 from December 14, 2024. It still declares React 19 support; Base UI's Drawer is the maintained alternative.</p>
<h3>Why do my vaul snap points not work with values like 50?</h3>
<p>Vaul multiplies each number by the screen height, so numbers must be fractions from 0 to 1 (0.5 for half the screen), even though a type comment says 0 to 100. Strings are read as pixels, so write "320px", never rem.</p>
<h3>How do I stop a vaul drawer from dragging when I swipe a carousel inside it?</h3>
<p>Set handleOnly so only the handle drags the sheet, or add the data-vaul-no-drag attribute to the carousel's root element. Vaul already ignores drags that start on a native select or while text is selected.</p>
<h3>Is the Wingo UI Drawer free?</h3>
<p>Yes. The Drawer, Family Drawer and Sheet are free and the Action Sheet is Pro. npx wingo-ui@latest add drawer copies the component into your project with the Button, hooks and helpers it imports.</p>
<hr />
<p><em>This article was originally published on the Wingo UI blog: <a href="https://wingo-ui.com/blog/react-bottom-sheet-for-the-web">React Bottom Sheet for the Web: Snap Points, Drag, Keyboard</a>. The original has the live, interactive demos and is kept up to date.</em></p>
]]></content:encoded></item><item><title><![CDATA[Claude Code shadcn Setup: MCP Server, Skill and CLAUDE.md]]></title><description><![CDATA[Claude Code shadcn setup, tested on shadcn 4.21.4: the MCP server, the skill, a second registry, a CLAUDE.md block and prompts that work.]]></description><link>https://wingo-ui.hashnode.dev/claude-code-shadcn-setup-mcp-server-skill-and-claude-md</link><guid isPermaLink="true">https://wingo-ui.hashnode.dev/claude-code-shadcn-setup-mcp-server-skill-and-claude-md</guid><category><![CDATA[react js]]></category><category><![CDATA[Artificial Intelligence]]></category><category><![CDATA[mcp]]></category><dc:creator><![CDATA[Serban Dan Rusu]]></dc:creator><pubDate>Thu, 22 Oct 2026 08:17:32 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ad6a430d0d0b34960b508b1/667a0bb0-2292-49b7-b762-860b85e5d8cf.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2>TL;DR</h2>
<p>Run npx shadcn@latest mcp init --client claude to add the shadcn MCP server to .mcp.json, install the shadcn skill with npx skills add shadcn/ui, and list every extra registry in components.json. Then add a short block to CLAUDE.md that says which registry owns which folder and which CLI installs and updates it, and name the registry in every prompt. The server finds components, the skill and the rules steer Claude, and the CLI writes the files.</p>
<p>Claude Code already knows shadcn/ui from its training data, and that is the problem. It writes the API it remembers, which may not match the files in your <code>components/ui</code>, and it has never seen the registries you added to <code>components.json</code> last month. A Claude Code shadcn setup that holds up has five parts: the shadcn MCP server so Claude can search real registries, the shadcn skill for project context, the registry list in <code>components.json</code>, a short rules file that says which registry owns which folder, and a CLI that writes the files. This tutorial sets up all five, adds a second registry, and ends with prompts shaped to get the right component installed.</p>
<p>Everything below was run in October 2026 against shadcn 4.21.4, the <code>skills</code> CLI 1.7.1 and our own registry, Wingo UI 1.7.0. What we say about Claude Code itself comes from its docs as of the same month. For the wider picture, read our <a href="https://wingo-ui.com/blog/component-library-mcp-guide">component library MCP guide</a> first.</p>
<h2>What does a Claude Code shadcn setup need?</h2>
<p>A server that searches registries, instructions that say when to use it, a list of registries and a CLI that installs:</p>
<table>
<thead>
<tr>
<th>Piece</th>
<th>Job</th>
<th>How you add it</th>
</tr>
</thead>
<tbody><tr>
<td>shadcn MCP server</td>
<td>Searches every registry in <code>components.json</code>, returns add commands</td>
<td><code>npx shadcn@latest mcp init --client claude</code></td>
</tr>
<tr>
<td>shadcn skill</td>
<td>Project config and shadcn/ui composition rules</td>
<td><code>npx skills add shadcn/ui</code></td>
</tr>
<tr>
<td><code>components.json</code></td>
<td>The registries to search, with auth headers</td>
<td>Edit by hand</td>
</tr>
<tr>
<td><code>CLAUDE.md</code> block</td>
<td>Which registry owns which folder and which CLI installs it</td>
<td>Six lines by hand</td>
</tr>
<tr>
<td>The CLI</td>
<td>Writes files, installs npm packages</td>
<td><code>npx shadcn@latest add</code> or a library's own</td>
</tr>
</tbody></table>
<p>A library's own MCP server is an optional sixth piece: the shadcn server returned no props in our tests, and a library server can. Ours joins shadcn's in the same <code>.mcp.json</code> through <code>npx wingo-ui@latest agents install claude</code>.</p>
<h2>How do I add the shadcn MCP server to Claude Code?</h2>
<p>Run one command in the project root, restart Claude Code and check <code>/mcp</code>:</p>
<pre><code class="language-bash">npx shadcn@latest mcp init --client claude
</code></pre>
<p>It writes this entry to <code>.mcp.json</code>, then installs <code>shadcn</code> as a dev dependency:</p>
<pre><code class="language-json">{
  "mcpServers": {
    "shadcn": {
      "command": "npx",
      "args": ["shadcn@latest", "mcp"]
    }
  }
}
</code></pre>
<p><code>.mcp.json</code> is Claude Code's project scope: commit it and the team gets the server, after each person approves it once. For a setup only you use, <code>claude mcp add shadcn -- npx shadcn@latest mcp</code> adds it in local scope, the default, instead.</p>
<h3>Which tools does the shadcn MCP server give Claude?</h3>
<p>Once the Claude Code shadcn MCP connection is up, Claude gets seven read-only tools. This is the list the server returned in 4.21.4:</p>
<ul>
<li><code>get_project_registries</code>: the registries in <code>components.json</code>.</li>
<li><code>list_items_in_registries</code> and <code>search_items_in_registries</code>: browse or fuzzy-search, with paging.</li>
<li><code>view_items_in_registries</code>: name, description, type, file count and npm dependencies.</li>
<li><code>get_item_examples_from_registries</code>: demo code, found by names like <code>dialog-demo</code>.</li>
<li><code>get_add_command_for_items</code>: the <code>npx shadcn@latest add</code> command for items.</li>
<li><code>get_audit_checklist</code>: a checklist for after generating code.</li>
</ul>
<p>Three details shape how you prompt it. The server never writes files: it hands Claude an add command, and every install is a Bash call you can read first. The server sends no instructions, and Claude Code's tool search loads only tool names and server instructions when a session starts, so the skill and your rules file have to say when to use it. And <code>view_items_in_registries</code> returned neither props nor source for <code>@shadcn/button</code> or our dialog; examples exist only where a registry publishes <code>-demo</code> items, which shadcn/ui does and we do not yet. For a third-party item, Claude learns the API from the installed file or the library's own docs.</p>
<p>One bug in 4.21.4: search results print each add command as <code>[object Promise]</code>. <code>get_add_command_for_items</code> returns the real one, <code>npx shadcn@latest add @shadcn/button @wingo-ui/dialog</code> in our test.</p>
<h2>How do I add a second registry, private or Pro?</h2>
<p>Add it under <code>registries</code> in <code>components.json</code>. A public registry needs only its URL template:</p>
<pre><code class="language-json">{
  "registries": {
    "@wingo-ui": "https://wingo-ui.com/r/{name}.json"
  }
}
</code></pre>
<p>A shadcn private registry takes headers, with the secret in an environment variable:</p>
<pre><code class="language-json">{
  "registries": {
    "@wingo-ui": {
      "url": "https://wingo-ui.com/r/{name}.json",
      "headers": { "Authorization": "Bearer ${WINGO_UI_API_KEY}" }
    }
  }
}
</code></pre>
<p>The shadcn CLI reads that variable from the environment or from <code>.env.local</code>; we confirmed both. When the key is set, <code>agents install</code> writes our own MCP entry in <code>.mcp.json</code> with the same <code>${WINGO_UI_API_KEY}</code>, and Claude Code expands it from the environment it started in. Export the key in your shell profile and both servers see it.</p>
<p>Use the header form only when the variable is set. Without it, every call that touches <code>@wingo-ui</code> fails with <code>MISSING_ENV_VARS</code>: a search across <code>@shadcn</code> and <code>@wingo-ui</code> returned only that error, with no shadcn results either. Free Wingo UI items need no key, so keep the plain URL until you have one.</p>
<p>Then one search covers both registries. This is the real answer to <code>search_items_in_registries</code> for "login" with a limit of 4, the broken add-command lines removed:</p>
<pre><code class="language-text">Found 173 items matching "login" in registries @shadcn, @wingo-ui:

Showing items 1-4 of 173:

- login-page (registry:block) - The sign-in screen of Northwind CRM: password, email link or code, Google / Apple / Microsoft, two-step codes and the whole password recovery. [@wingo-ui]
- login-01 (registry:block) - A simple login form. [@shadcn]
- login-05 (registry:block) - A simple email-only login page. [@shadcn]
- login-02 (registry:block) - A two column login page with a cover image. [@shadcn]
</code></pre>
<p>173 matches for one word is fuzzy matching at work (<code>@shadcn</code> alone returned 7), so ask for a small limit.</p>
<h3>What happens when two registries both write components/ui/button.tsx?</h3>
<p>Neither CLI replaces the other's file silently, and either answer breaks something. shadcn/ui and many shadcn-style libraries install into <code>components/ui</code> with the same file names and different APIs. When we added our <a href="https://wingo-ui.com/components/dialog">Dialog</a> to a project that already had the shadcn button, our CLI reported a conflict on <code>components/ui/button.tsx</code>, kept the shadcn file and still wrote the dialog and drawer next to it; the shadcn CLI asks before it overwrites. Keeping the shadcn button breaks the type-check, because our dialog and drawer render their close buttons with <code>radius="full"</code>, a prop shadcn's Button does not have. Replacing it breaks every <code>variant="default"</code>, <code>"secondary"</code> or <code>"destructive"</code> in your app, because our <a href="https://wingo-ui.com/components/button">Button</a> variants are <code>solid</code>, <code>soft</code>, <code>outline</code>, <code>ghost</code> and <code>link</code> (destructive is <code>tone="danger"</code>).</p>
<p>Give the second library its own folder. Our CLI reads its paths from <code>wingo-ui.json</code>, so after <code>npx wingo-ui@latest init</code>, change the <code>ui</code> alias and keep the rest:</p>
<pre><code class="language-json">{
  "aliases": {
    "ui": "@/components/wingo",
    "blocks": "@/components/blocks",
    "hooks": "@/hooks",
    "lib": "@/lib",
    "utils": "@/lib/utils"
  }
}
</code></pre>
<p><code>npx wingo-ui@latest add dialog</code> then wrote <code>button.tsx</code>, <code>drawer.tsx</code> and <code>dialog.tsx</code> into <code>components/wingo</code> beside the untouched shadcn button and rewrote their imports to <code>@/components/wingo/...</code>. <code>init</code> had already merged our design tokens into <code>app/globals.css</code>, and <code>add</code> appended the drawer's transitions. That is the second reason to install our items with our CLI: the shadcn-format JSON at <code>/r/&lt;slug&gt;.json</code> carries no tokens, so in a project without them, <code>z-modal</code> and <code>shadow-modal</code> on a shadcn-installed dialog resolve to nothing.</p>
<h2>Should I install the Claude Code shadcn skill too?</h2>
<p>Yes, if your project uses shadcn/ui components; if it also uses another library, scope the skill in your rules file. The skill is the official instruction set from the shadcn/ui repository:</p>
<pre><code class="language-bash">npx skills add shadcn/ui --agent claude-code
</code></pre>
<p>The <code>skills</code> CLI wrote two skills into <code>.claude/skills</code>, <code>shadcn</code> and <code>migrate-radix-to-base</code>, and recorded both in <code>skills-lock.json</code>. Claude Code keeps only a skill's description in context until the skill is used, so the 19.5 KB <code>SKILL.md</code> costs little until a UI task starts. Its description matches any project with a <code>components.json</code>, so expect it on most UI work.</p>
<p>When it loads, it runs <code>npx shadcn@latest info --json</code> and puts your framework, Tailwind version, aliases, base library and installed components in front of Claude. Its <code>allowed-tools</code> line also lets Claude run any <code>npx shadcn@latest</code> command without asking for the rest of that turn, <code>add --overwrite</code> included. Three of its rules matter before you prompt:</p>
<ul>
<li><strong>Name the registry.</strong> Ask for "a login block" without one and the skill tells Claude to ask you which registry to use.</li>
<li><strong>Docs first.</strong> Claude runs <code>npx shadcn@latest docs &lt;component&gt;</code> before using one. That command only knows shadcn/ui: <code>docs dialog</code> returned links to the shadcn/ui and Radix Dialog docs, and <code>docs @wingo-ui/dialog</code> answered that the component was not found in the shadcn registry.</li>
<li><strong>shadcn/ui composition.</strong> Forms use <code>FieldGroup</code> and <code>Field</code>, icons in buttons take <code>data-icon</code>, and since shadcn's Button has no loading prop, Claude composes a <code>Spinner</code> and <code>disabled</code> by hand.</li>
</ul>
<p>All three are right for shadcn/ui files, and the last two are wrong for other registries. Our Button has a <code>loading</code> prop that shows a spinner in place of the label, keeps the width, sets <code>aria-busy</code> and ignores clicks, so a hand-built spinner duplicates it. In the demo below, pressing "Save changes" swaps the label for a spinner and "Saving" while the button keeps its width.</p>
<blockquote>
<p><strong>Live demo: Button.</strong> The original article embeds an interactive demo here (Click Save changes: the spinner takes the label's place, the width holds and the button ignores clicks until it settles). <a href="https://wingo-ui.com/blog/claude-code-shadcn-mcp-setup">Try it live on wingo-ui.com</a> or open the <a href="https://wingo-ui.com/components/button">Button docs</a>. Install: <code>npx wingo-ui@latest add button</code></p>
</blockquote>
<h2>What should CLAUDE.md say?</h2>
<p>Who owns which folder, which tools to search with, which CLI installs and updates, and where the shadcn skill's rules stop. Claude Code loads <code>CLAUDE.md</code> at the start of every session, so this block is always in context:</p>
<pre><code class="language-md">## UI components

- Two registries. `components/ui` is shadcn/ui (`@shadcn` in components.json). `components/wingo` is Wingo UI (versions in wingo-ui.json); import it from `@/components/wingo/&lt;name&gt;`.
- Before writing UI, search: the `shadcn` MCP tools for @shadcn and other registries, the `wingo-ui` MCP tools for Wingo UI. Never hand-write a component a registry has.
- Install shadcn items with `npx shadcn@latest add @shadcn/&lt;name&gt;` and Wingo UI items with `npx wingo-ui@latest add &lt;slug&gt;`. Never install a Wingo UI item with the shadcn CLI.
- Read the API of the file you import. The shadcn skill's rules and `shadcn docs` apply to `components/ui` only; for Wingo UI items call `get_component` and configure them through their props.
- Semantic tokens only, no hex colors. Check 390px and 1440px wide, in light and dark.
- Never pass `--overwrite` without asking me. Before any update, show me the diff or the release notes.
</code></pre>
<p>The first line answers what no tool can: which folder belongs to whom. It matters twice in this layout, because our own skill, which <code>agents install</code> writes, assumes the default folder and tells Claude that imports use <code>@/components/ui/&lt;name&gt;</code>. The fourth line keeps shadcn/ui patterns out of files with a different API. The last repeats the skill's own <code>--overwrite</code> rule, because the skill's permission grant covers that flag. On shadcn/ui alone, keep the search, install and overwrite lines. If Codex or Cursor work in the same repository, put these lines in AGENTS.md and import it from CLAUDE.md; our <a href="https://wingo-ui.com/blog/agents-md-vs-claude-md-react">AGENTS.md vs CLAUDE.md comparison</a> explains which file each agent reads.</p>
<p>Allow the read-only tools in <code>.claude/settings.json</code> and leave installs on ask:</p>
<pre><code class="language-json">{
  "permissions": {
    "allow": [
      "mcp__shadcn",
      "mcp__wingo-ui",
      "Bash(npx shadcn@latest search *)",
      "Bash(npx shadcn@latest view *)",
      "Bash(npx wingo-ui@latest outdated)"
    ]
  }
}
</code></pre>
<h2>Which prompts get Claude to install the right component?</h2>
<p>The ones that name the registry, ask for a search before an install, and say what to check at the end. Five prompts built that way:</p>
<ol>
<li><strong>Find before you build.</strong> "Search @shadcn and @wingo-ui for a modal that becomes a bottom sheet on phones. List the candidates with their registry and install command, and install nothing yet." Naming both registries satisfies the skill's rule, and "install nothing yet" keeps the first step read-only.</li>
<li><strong>Build on one component's real API.</strong> "Add the Wingo UI dialog and input with the Wingo CLI, read their props with get_component, then build a profile dialog in app/settings/profile-dialog.tsx with a name field, Cancel and Save, a loading state on Save and an error if saving fails." Naming the source of the API steers Claude away from the shadcn/ui Dialog it remembers.</li>
<li><strong>Start screens from a block.</strong> "Build /login from the Wingo UI login-page block. Wire onSignIn and onVerifyCode to our /api/auth routes, hide the social providers, and list every on* handler you left on its simulated default." The <a href="https://wingo-ui.com/components/login-page">Login Page</a> ships simulated handlers so its demo works, and each one you do not pass stays simulated: without <code>onVerifyCode</code>, the code views accept <code>123456</code>.</li>
<li><strong>Ask for updates as a report.</strong> "Check my Wingo UI components and my shadcn/ui button for upstream changes. Show me the notes and the diff, and change nothing."</li>
<li><strong>End with a check.</strong> "Run get_audit_checklist, then type-check and lint the files you touched." The checklist is six generic lines; the type-check is what catches a wrong prop, such as <code>variant="destructive"</code> on our Button.</li>
</ol>
<p>For prompt 2, this is the file a good run ends with, on the real APIs of the <a href="https://wingo-ui.com/components/dialog">Dialog</a>, the Button and the <a href="https://wingo-ui.com/components/input">Input</a>. We type-checked it against release 1.7.0:</p>
<pre><code class="language-tsx">// app/settings/profile-dialog.tsx
"use client";

import { useState } from "react";
import { Pencil } from "lucide-react";
// next to shadcn/ui these imports read @/components/wingo/...
import { Button } from "@/components/ui/button";
import { Dialog, DialogClose } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";

type ProfileDialogProps = {
  initialName: string;
  onSave: (name: string) =&gt; Promise&lt;void&gt;;
};

export function ProfileDialog({ initialName, onSave }: ProfileDialogProps) {
  const [open, setOpen] = useState(false);
  const [name, setName] = useState(initialName);
  const [saving, setSaving] = useState(false);
  const [error, setError] = useState("");

  function changeOpen(next: boolean) {
    // start from the saved name every time it opens
    if (next) {
      setName(initialName);
      setError("");
    }
    setOpen(next);
  }

  async function save() {
    setSaving(true);
    setError("");
    try {
      await onSave(name.trim());
      setOpen(false);
    } catch {
      setError("Your name was not saved. Try again.");
    } finally {
      setSaving(false);
    }
  }

  return (
    &lt;Dialog
      open={open}
      onOpenChange={changeOpen}
      trigger={
        &lt;Button variant="soft" leftIcon={&lt;Pencil /&gt;}&gt;
          Edit profile
        &lt;/Button&gt;
      }
      title="Edit profile"
      description="Your name appears on invoices and in comments."
      icon={&lt;Pencil /&gt;}
      footer={
        &lt;&gt;
          &lt;DialogClose asChild&gt;
            &lt;Button variant="soft"&gt;Cancel&lt;/Button&gt;
          &lt;/DialogClose&gt;
          &lt;Button tone="brand" loading={saving} loadingText="Saving" onClick={save}&gt;
            Save
          &lt;/Button&gt;
        &lt;/&gt;
      }
    &gt;
      &lt;Input
        label="Full name"
        name="name"
        autoComplete="name"
        value={name}
        onValueChange={setName}
        error={error}
      /&gt;
    &lt;/Dialog&gt;
  );
}
</code></pre>
<p>There is no media query in that file. Below 768px the same Dialog opens as a bottom sheet you can drag down to close, with the actions stacked full width at 48px and the primary on top; our <a href="https://wingo-ui.com/blog/shadcn-responsive-dialog-on-mobile">shadcn responsive dialog</a> tutorial explains how that switch avoids a hydration flash. The demo below is a listing card for a city bike in Brooklyn at $420, and its Edit button opens the same kind of dialog.</p>
<blockquote>
<p><strong>Live demo: Dialog.</strong> The original article embeds an interactive demo here (Click Edit, change the price and press Save: the button shows its loading state, then the dialog closes; on a phone the same dialog opens as a bottom sheet). <a href="https://wingo-ui.com/blog/claude-code-shadcn-mcp-setup">Try it live on wingo-ui.com</a> or open the <a href="https://wingo-ui.com/components/dialog">Dialog docs</a>. Install: <code>npx wingo-ui@latest add dialog</code></p>
</blockquote>
<h2>How do I keep the installed components up to date?</h2>
<p>Ask Claude for a report, then decide. The two CLIs treat edited files differently, which is why the rules file sends each folder to its own tool.</p>
<p>For shadcn/ui files, <code>npx shadcn@latest add button --diff</code> shows your file against the registry, and <code>--overwrite</code> replaces it, edits included. The skill tells Claude to merge by hand when a file has local changes. Our guide to <a href="https://wingo-ui.com/blog/update-shadcn-components-without-losing-edits">updating shadcn components without losing edits</a> has a git script that runs a real 3-way merge instead.</p>
<p>For Wingo UI files, the CLI recorded each item's version and kept a pristine copy at install, so it merges for you:</p>
<pre><code class="language-bash">npx wingo-ui@latest outdated        # installed items vs the latest release
npx wingo-ui@latest diff dialog     # your file vs upstream
npx wingo-ui@latest update dialog   # 3-way merge that keeps your edits
</code></pre>
<p>With our MCP server connected, <code>check_updates</code> returns the same list with release notes, so prompt 4 works inside the session.</p>
<h2>Where should I start?</h2>
<p>On shadcn/ui alone: add the MCP server, install the skill, write the three rules lines, and name <code>@shadcn</code> in every prompt. If you want a second registry built for phones, the Dialog, Button and Input from this post are free; <code>npx wingo-ui@latest agents install claude</code> adds our MCP server and skill next to shadcn's, and the <a href="https://wingo-ui.com/docs/mcp">MCP server docs</a> cover the setup. The Login Page block is part of Wingo UI Pro, on the <a href="https://wingo-ui.com/pricing">pricing page</a>. More guides like this one live under <a href="https://wingo-ui.com/blog/category/ai-agents">Building UI with AI agents</a>.</p>
<h2>Components in this post</h2>
<ul>
<li><a href="https://wingo-ui.com/components/button">Button</a> (Free): The button every screen starts with: five variants, six tones, three sizes plus icon sizes, and a loading state that never jumps. Install: <code>npx wingo-ui@latest add button</code></li>
<li><a href="https://wingo-ui.com/components/dialog">Dialog</a> (Free): The modal for focused tasks: a spring-scaled card on desktop that becomes a bottom sheet on phones, with nesting and a scrolling body. Install: <code>npx wingo-ui@latest add dialog</code></li>
<li><a href="https://wingo-ui.com/components/login-page">Login Page</a> (Pro): The sign-in screen of Northwind CRM: password, email link or code, Google / Apple / Microsoft, two-step codes and the whole password recovery. Install: <code>npx wingo-ui@latest add login-page</code></li>
<li><a href="https://wingo-ui.com/components/input">Input</a> (Free): The single-line text field: icons and addons inside the box, a clear button, a loading spinner, a counter and floating labels. Install: <code>npx wingo-ui@latest add input</code></li>
</ul>
<h2>FAQ</h2>
<h3>How do I install the shadcn MCP server in Claude Code?</h3>
<p>Run npx shadcn@latest mcp init --client claude in the project root. It writes a shadcn entry to .mcp.json and installs shadcn as a dev dependency; restart Claude Code and run /mcp to check that the server connected.</p>
<h3>What is the difference between the shadcn skill and the shadcn MCP server?</h3>
<p>The MCP server gives Claude tools to list, search and view registry items and to get their add commands. The skill adds no tools: it injects your project config from shadcn info --json and the shadcn/ui composition rules into the session when Claude works on UI.</p>
<h3>Does the shadcn MCP server install components by itself?</h3>
<p>No. As of shadcn 4.21.4 its seven tools are read-only: it returns the npx shadcn@latest add command and Claude runs it in the terminal. That Bash call normally asks for your approval, but during a turn where the shadcn skill is active, the skill's allowed-tools line lets it run without asking.</p>
<h3>Why does a shadcn MCP search fail with MISSING_ENV_VARS?</h3>
<p>A registry in components.json uses an environment variable in its headers that is not set. The shadcn CLI then fails the whole call, other registries included. Export the variable or put it in .env.local, or use the plain URL form for public registries.</p>
<h3>Can I use shadcn/ui and another registry in the same project?</h3>
<p>Yes, but many shadcn-style libraries install into components/ui with the same file names, starting with button.tsx. Give the second library its own folder (Wingo UI reads it from aliases.ui in wingo-ui.json) so neither CLI has to overwrite the other's files.</p>
<hr />
<p><em>This article was originally published on the Wingo UI blog: <a href="https://wingo-ui.com/blog/claude-code-shadcn-mcp-setup">Claude Code shadcn Setup: MCP Server, Skill and CLAUDE.md</a>. The original has the live, interactive demos and is kept up to date.</em></p>
]]></content:encoded></item><item><title><![CDATA[14 shadcn Alternatives in 2026: Price, Mobile and MCP]]></title><description><![CDATA[Free and paid UI libraries checked in October 2026 by license, price, mobile behavior and MCP support, with a pick per use case.]]></description><link>https://wingo-ui.hashnode.dev/14-shadcn-alternatives-in-2026-price-mobile-and-mcp</link><guid isPermaLink="true">https://wingo-ui.hashnode.dev/14-shadcn-alternatives-in-2026-price-mobile-and-mcp</guid><category><![CDATA[React]]></category><category><![CDATA[Tailwind CSS]]></category><category><![CDATA[Web Development]]></category><dc:creator><![CDATA[Serban Dan Rusu]]></dc:creator><pubDate>Mon, 19 Oct 2026 23:56:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ad6a430d0d0b34960b508b1/cd40adad-2e26-42cf-8019-15adcf45a644.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2>TL;DR</h2>
<p>The best shadcn alternative depends on which part of shadcn/ui you want to replace. To keep owning the source, pick coss ui (free, on Base UI), Magic UI or Aceternity UI for animated marketing pages, or Wingo UI for mobile-first app components; to install a package instead, pick Mantine, MUI, Chakra UI or HeroUI; for a design system of your own, start from Base UI or React Aria. Most of these libraries leave the phone layout to you, so check what each one does on a 390px screen before you switch.</p>
<p>Picking among shadcn alternatives usually starts with how each library installs and how it looks. The questions that decide a project come later: what happens to the files you copied when upstream fixes a bug, whether the select you ship opens as something usable on a 390px phone, and whether Claude Code or Cursor can look up real props instead of guessing them. This guide answers those questions for 14 libraries, with prices, licenses and features checked in October 2026 and linked to the pages we read.</p>
<p>We build Wingo UI, one of the paid options below, so read this as a vendor's view with sources. It covers open source shadcn alternatives and paid ones, and it says plainly where another library is the better pick. More head to head comparisons live under <a href="https://wingo-ui.com/blog/category/comparisons">Comparisons and alternatives</a>.</p>
<h2>What is the best alternative to shadcn UI?</h2>
<p>There is no single best alternative to shadcn UI, because shadcn/ui is three things at once: a way to distribute code (a CLI that copies source files into your project), a set of primitives underneath (Base UI, Radix or React Aria), and a visual style. Decide which of the three you want to replace and the shortlist gets short:</p>
<ul>
<li><strong>Keep the model, change the components.</strong> coss ui for free MIT components on Base UI, Magic UI or Aceternity UI for animated landing pages, Untitled UI React for a kit that matches the Untitled UI Figma file, Wingo UI for mobile-first app components with an MCP server and updates that merge into files you edited.</li>
<li><strong>Leave the model, install a package.</strong> Mantine for a free set with forms, dates and charts, MUI when you need the MUI X data grid and pickers, Chakra UI for style props, HeroUI for React Aria and Tailwind CSS v4 in an npm package, Ant Design for dense enterprise back offices.</li>
<li><strong>Keep the primitives, drop the look.</strong> Base UI, React Aria Components or Radix Primitives, styled by you.</li>
<li><strong>Step outside React.</strong> daisyUI, which is a set of Tailwind CSS class names that works with any framework.</li>
</ul>
<p>If none of those reasons applies to you, the best option may be staying where you are, which the section near the end covers.</p>
<h2>What changed in shadcn/ui in 2026?</h2>
<p>The biggest change: since July 2026, new shadcn/ui projects start on Base UI instead of Radix. The <a href="https://ui.shadcn.com/docs/changelog/2026-07-base-ui-default">July 2026 changelog entry</a> made Base UI the default for <code>npx shadcn init</code> and for the docs, kept Radix fully supported behind <code>npx shadcn init -b radix</code>, and noted that projects created on shadcn/create were already picking Base UI over Radix 2 to 1. The same month, <a href="https://ui.shadcn.com/docs/changelog/2026-07-react-aria">React Aria became a third base</a> (<code>--base aria</code>).</p>
<p>Other changes that matter when you compare:</p>
<ul>
<li>The <a href="https://ui.shadcn.com/docs/components/drawer">Drawer</a> in Base UI projects now uses Base UI's drawer instead of Vaul. The Radix version still uses Vaul.</li>
<li>June 2026 added components for chat interfaces, July 2026 a Toast built on Base UI, and September 2026 moved <code>cn</code> into <a href="https://ui.shadcn.com/docs/changelog/2026-09-cn">its own package</a>, so components import it from <code>cn</code> instead of <code>@/lib/utils</code>. What the new Toast means next to Sonner is in our <a href="https://wingo-ui.com/blog/sonner-alternative-react-toast">Sonner alternatives comparison</a>.</li>
<li>On the agent side there is an <a href="https://ui.shadcn.com/docs/mcp">MCP server</a> that browses, searches and installs from every registry in <code>components.json</code>, and a <a href="https://ui.shadcn.com/docs/skills">skill</a> (<code>npx skills add shadcn/ui</code>) that reads your project configuration before the agent writes code.</li>
</ul>
<p>Two consequences follow. A "Radix plus Tailwind" description of shadcn/ui is out of date, and a registry written for Radix needs care in a new Base UI project. And the copy-the-source model has become a platform: registries from other vendors install through the same <code>shadcn add</code> command, so trying an alternative no longer means learning a new tool.</p>
<h2>How do the shadcn alternatives compare?</h2>
<p>The tables below are the short answer, checked in October 2026. "Mobile" lists only what each library's docs show for phones beyond responsive layout, and "Agents" what a coding agent can use. Prices are list prices on the day we checked; several vendors showed a discount from a higher price.</p>
<h3>Libraries that put the source in your project</h3>
<table>
<thead>
<tr>
<th>Library</th>
<th>Model and base</th>
<th>License</th>
<th>Price</th>
<th>Mobile</th>
<th>Agents</th>
</tr>
</thead>
<tbody><tr>
<td><a href="https://ui.shadcn.com/docs/changelog">shadcn/ui</a></td>
<td>CLI copies source; Base UI (default), Radix or React Aria</td>
<td>MIT</td>
<td>Free</td>
<td>Drawer, a Dialog plus Drawer recipe, Sidebar with its own mobile state</td>
<td>MCP server, skill, llms.txt</td>
</tr>
<tr>
<td><a href="https://coss.com/ui/llms.txt">coss ui</a></td>
<td>shadcn registry (<code>@coss</code>); Base UI</td>
<td>MIT</td>
<td>Free</td>
<td>Drawer with swipe, snap points and nesting</td>
<td>llms.txt, agent skills</td>
</tr>
<tr>
<td><a href="https://pro.magicui.design">Magic UI</a></td>
<td>shadcn registry (<code>@magicui</code>); Motion</td>
<td>MIT (free), paid license (Pro)</td>
<td>Free; Pro $199 one-time</td>
<td>Landing page effects; no sheet, popover or menu in the catalog</td>
<td><a href="https://magicui.design/docs/mcp">MCP server</a>, llms.txt</td>
</tr>
<tr>
<td><a href="https://ui.aceternity.com/pricing">Aceternity UI</a></td>
<td>shadcn registry (<code>@aceternity</code>); Motion</td>
<td><a href="https://ui.aceternity.com/licence">Aceternity License</a></td>
<td>Free; $169 a year or $199 lifetime; Team $1,590 for 10 people</td>
<td>Effects and blocks; some blocks document a mobile menu</td>
<td>llms.txt; prompts for v0 and Lovable on paid plans</td>
</tr>
<tr>
<td><a href="https://www.untitledui.com/react">Untitled UI React</a></td>
<td>Own CLI or npm; React Aria, Tailwind CSS v4</td>
<td>MIT (free base), PRO license</td>
<td>Free; PRO from $349 one-time for one user</td>
<td>Drawers and modals are PRO</td>
<td>MCP server, llms.txt</td>
</tr>
<tr>
<td><a href="https://wingo-ui.com/docs/installation">Wingo UI</a></td>
<td>Own CLI or shadcn registry (<code>@wingo-ui</code>); Radix, vaul, Motion</td>
<td>Per-developer license; 75 items free</td>
<td>Free; Pro $8 a month, $80 a year or $150 lifetime</td>
<td>Popover, select, menus and dialog open as bottom sheets below 768px; 44px targets on touch; table rows become cards</td>
<td><a href="https://wingo-ui.com/docs/mcp">MCP server</a>, rules for 5 agents, llms.txt, update checks</td>
</tr>
</tbody></table>
<h3>Packages and primitives</h3>
<table>
<thead>
<tr>
<th>Library</th>
<th>Model and base</th>
<th>License</th>
<th>Price</th>
<th>Mobile</th>
<th>Agents</th>
</tr>
</thead>
<tbody><tr>
<td><a href="https://mantine.dev/guides/llms/">Mantine 9</a></td>
<td>npm; own CSS, no Tailwind</td>
<td>MIT</td>
<td>Free</td>
<td>Modal goes full screen on small screens with <code>use-media-query</code> (a docs recipe)</td>
<td>MCP server, skills, llms.txt</td>
</tr>
<tr>
<td><a href="https://mui.com/pricing/">MUI (Material UI 9)</a></td>
<td>npm; Emotion</td>
<td>MIT core</td>
<td>Free; MUI X Pro $299 or Premium $599 per developer a year</td>
<td>SwipeableDrawer; Dialog goes full screen with <code>useMediaQuery</code> (a docs recipe)</td>
<td><a href="https://mui.com/material-ui/getting-started/mcp/">MCP server</a>, llms.txt</td>
</tr>
<tr>
<td><a href="https://www.chakra-ui.com/docs/get-started/ai/mcp-server">Chakra UI 3</a></td>
<td>npm; Ark UI, Emotion</td>
<td>MIT</td>
<td>Free; <a href="https://pro.chakra-ui.com/pricing">Chakra UI Pro</a> blocks $299 one-time</td>
<td>Drawer placement takes breakpoints, such as bottom on phones and end from <code>md</code></td>
<td>MCP server, llms.txt</td>
</tr>
<tr>
<td><a href="https://www.heroui.com/llms.txt">HeroUI 3</a></td>
<td>npm; React Aria, Tailwind CSS v4</td>
<td>MIT</td>
<td>Free; <a href="https://heroui.pro/pricing">HeroUI Pro</a> sold separately</td>
<td>Modal placement <code>auto</code> sits at the bottom on mobile and centered on desktop; a separate React Native library</td>
<td>MCP server, agent skills, llms.txt</td>
</tr>
<tr>
<td><a href="https://ant.design/docs/react/mcp">Ant Design 6</a></td>
<td>npm; own design language</td>
<td>MIT</td>
<td>Free</td>
<td>Built for enterprise back offices; phones get a separate library, antd-mobile</td>
<td>MCP server (<code>antd mcp</code>), llms.txt</td>
</tr>
<tr>
<td><a href="https://daisyui.com/components/modal/">daisyUI 5</a></td>
<td>Tailwind CSS plugin, class names only</td>
<td>MIT</td>
<td>Free; <a href="https://daisyui.com/blueprint/">Blueprint MCP</a> $22 a month or $600 lifetime</td>
<td><code>modal-bottom sm:modal-middle</code> puts a modal at the bottom on phones; Dock for bottom navigation</td>
<td>llms.txt, skill; Blueprint MCP (paid)</td>
</tr>
<tr>
<td><a href="https://konstaui.com/">Konsta UI 5</a></td>
<td>npm for React, Vue and Svelte; Tailwind CSS</td>
<td>MIT</td>
<td>Free</td>
<td>Mobile only: iOS and Material sheets, tab bars and toolbars</td>
<td>None found</td>
</tr>
<tr>
<td><a href="https://base-ui.com/llms.txt">Base UI 1.8</a></td>
<td>npm; unstyled</td>
<td>MIT</td>
<td>Free</td>
<td>Drawer with swipe to dismiss</td>
<td>llms.txt</td>
</tr>
<tr>
<td><a href="https://react-aria.adobe.com/llms.txt">React Aria Components</a></td>
<td>npm; unstyled</td>
<td>Apache-2.0</td>
<td>Free</td>
<td>Press and hover events normalized for touch</td>
<td>MCP server, agent skills, llms.txt</td>
</tr>
</tbody></table>
<p>Two libraries are missing from the tables on purpose. Radix Primitives is still maintained and still the base of many registries, ours included; it appears in the section on headless primitives. Tailwind Plus, from the Tailwind CSS team, sells its components, templates and the Catalyst UI kit as a <a href="https://tailwindcss.com/blog/tailwind-plus">one-time purchase with lifetime access</a>, but its product page sent us to a sign-in form when we checked, so we left it out rather than print prices we could not see.</p>
<h2>Which alternatives keep the shadcn model?</h2>
<p>coss ui, Magic UI, Aceternity UI, Untitled UI React and Wingo UI all put the source in your repository, and all but Untitled UI install through the shadcn CLI. They differ in what they cover and in what happens after the copy.</p>
<p><strong>coss ui</strong> is the closest thing to shadcn/ui with a different design. Its registry components are MIT licensed, built on Base UI from the start, cover the app basics from Accordion and Dialog to Toast and Tooltip, and come with a migration guide from shadcn/ui and Radix. Its <a href="https://github.com/cosscom/coss">repository</a> also keeps Origin UI, the Radix-based registry coss ui grew out of, as a legacy snapshot with limited support. If you liked shadcn/ui and want more components on the new default base for free, start here.</p>
<p><strong>Magic UI and Aceternity UI</strong> are effect libraries for marketing pages: animated backgrounds, marquees, hero sections and full landing templates. They sit next to shadcn/ui more than they replace it, since neither covers the forms, menus and overlays an app needs. Magic UI's free components are MIT; Aceternity uses its own license, which says you cannot redistribute the source files, so read it before you publish them in a public repository.</p>
<p><strong>Untitled UI React</strong> is the code side of the Untitled UI Figma kit, installed with its own CLI (<code>npx untitledui@latest add</code>) or from npm. The free base is MIT; the PRO tier is a one-time purchase per seat tier and adds hundreds of components, dashboards and page examples, drawers, modals and command menus included. It is a natural pick when your designers already work from the Untitled UI Figma file.</p>
<p><strong>Wingo UI</strong> is ours. Release 1.7.0 has 326 items: 218 components, 72 blocks and page templates, 17 hooks and 19 helpers, of which 75 are free. Every component is built for apps people open on phones, as the mobile section below shows. Every visual aspect is a prop with a default, and the CLI merges new releases into files you have edited. The weak spots: it is younger and has a much smaller community than shadcn/ui, it is React only, it is built on Radix (so a Base UI project would carry two primitive libraries), and most items need a paid plan.</p>
<h2>Which alternatives ship as an npm package?</h2>
<p>Mantine, MUI, Chakra UI, HeroUI and Ant Design. You upgrade with <code>npm update</code> instead of merging files, and you customize through the library's theme and style APIs instead of editing the component source. That trade is right for many teams: someone else fixes the focus trap in the date picker, and you never see the file.</p>
<p><strong>Mantine 9</strong> is free and MIT across the board: components, hooks, a form library, dates and charts. It does not use Tailwind CSS; its own FAQ has a page on the limits of mixing in third-party styling. Its <a href="https://mantine.dev/guides/llms/">AI guide</a> documents llms.txt, skills and an MCP server whose <code>get_item_props</code> tool returns a component's props or a hook's signature.</p>
<p><strong>MUI</strong> is where you go for heavy data screens. The core Material UI library is MIT and free; the MUI X Data Grid, date range pickers, charts and tree view add advanced features in paid plans, listed at $299 (Pro) and $599 (Premium) per developer per year in October 2026. The default look is Material Design, which is a feature or a problem depending on your product.</p>
<p><strong>Chakra UI 3</strong> rebuilt the library on Ark UI and kept its style props. Its responsive object syntax reaches component props too: the Drawer docs show <code>placement={{ mdDown: "bottom", md: "end" }}</code>, which opens a bottom drawer on phones and a side panel from <code>md</code>. Chakra UI Pro sells blocks on top, from $299 one-time.</p>
<p><strong>HeroUI 3</strong> is the closest npm package to the shadcn stack: React Aria Components underneath, Tailwind CSS v4 for styling, MIT. Its Modal placement defaults to <code>auto</code>, which sits at the bottom of the screen below the <code>sm</code> breakpoint and centered above it, and its <a href="https://github.com/heroui-inc/heroui/blob/v3/packages/styles/components/button.css">button styles</a> make the default size 40px tall on phones and 36px from <code>md</code> up. A separate HeroUI Native library covers React Native, and a paid HeroUI Pro adds components such as a command palette and a data grid.</p>
<p><strong>Ant Design 6</strong> is built for enterprise back offices: dense tables, forms and admin layouts. Phones get a separate library, antd-mobile, so an app that serves both maintains two sets of components.</p>
<h2>Should you use headless primitives instead?</h2>
<p>Only if you are building your own design system and can own every style, state and animation. Headless libraries give you behavior and accessibility; everything you see is your code.</p>
<ul>
<li><strong>Base UI</strong> reached <a href="https://base-ui.com/llms.txt">v1.0.0 on December 11, 2025</a> and is now the default base under shadcn/ui. Its Drawer handles swipe to dismiss, the job shadcn/ui used Vaul for.</li>
<li><strong>React Aria Components</strong> from Adobe has the most thorough interaction layer we know of. <code>usePress</code> handles mouse, touch, keyboard and screen readers through one API, and <code>useHover</code> <a href="https://react-aria.adobe.com/useHover.md">ignores the emulated mouse events that touch devices fire</a>, which is the cause of hover styles that stick after a tap.</li>
<li><strong>Radix Primitives</strong> is still maintained and still under most of the shadcn ecosystem, including Wingo UI.</li>
</ul>
<p>Picking a headless library also picks the base for everything you add later, since a registry built for one base may not drop cleanly into another.</p>
<h2>Which shadcn alternatives work best on mobile?</h2>
<p>Most of them leave the phone to you. They give you a drawer and a media query hook, and you wire the swap from popover or dialog to sheet one component at a time. Sorted by how much of that work the library does, from least to most:</p>
<ol>
<li><strong>Responsive CSS only.</strong> Magic UI and Aceternity UI (effects for landing pages) and Ant Design (a separate mobile library).</li>
<li><strong>A recipe you copy per component.</strong> shadcn/ui, MUI and Mantine document a pattern that reads a media query and renders a different component or a full-screen variant.</li>
<li><strong>A placement that changes by breakpoint.</strong> Chakra UI's drawer placement, daisyUI's <code>modal-bottom sm:modal-middle</code> and HeroUI's <code>auto</code> placement move the panel to the bottom of the screen on phones. Whether you can drag it away depends on the component.</li>
<li><strong>Built into each component.</strong> Konsta UI, for mobile-only apps with an iOS or Material look, and Wingo UI, where the <a href="https://wingo-ui.com/components/popover">Popover</a>, <a href="https://wingo-ui.com/components/select">Select</a>, Dropdown Menu, <a href="https://wingo-ui.com/components/dialog">Dialog</a> and date pickers open as draggable bottom sheets below 768px through a <code>responsive</code> prop that defaults to <code>true</code>.</li>
</ol>
<p>Level 2 looks like this. It is an abridged copy of the responsive example in the shadcn/ui Drawer docs, where the form is shared as its own component and the overlay shell is written twice:</p>
<pre><code class="language-tsx">const isDesktop = useMediaQuery("(min-width: 768px)");

if (isDesktop) {
  return (
    &lt;Dialog open={open} onOpenChange={setOpen}&gt;
      {/* trigger, header and &lt;ProfileForm /&gt; */}
    &lt;/Dialog&gt;
  );
}

return (
  &lt;Drawer open={open} onOpenChange={setOpen}&gt;
    {/* the same trigger and header again, then &lt;ProfileForm /&gt; */}
  &lt;/Drawer&gt;
);
</code></pre>
<p>That is fine for one dialog. It becomes a project when every popover, select, dropdown and date picker needs the same treatment, each with its shell written twice and each reading a hook that returns <code>false</code> on the server. Our <a href="https://wingo-ui.com/blog/mobile-first-react-components">mobile-first React components guide</a> covers why that hook value is harmless for overlays and harmful for content, along with the other rules for phones.</p>
<p>The level 4 version of the same idea is one component. The free <a href="https://wingo-ui.com/components/drawer">Drawer</a> is a draggable bottom sheet on phones, and its <code>desktop</code> prop decides what it becomes from 768px: the same sheet (<code>"sheet"</code>, the default), a side panel (<code>"side"</code>) or a centered dialog (<code>"dialog"</code>).</p>
<pre><code class="language-tsx">"use client";

import { SlidersHorizontal } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Drawer, DrawerClose } from "@/components/ui/drawer";

export function ListingFilters({ results }: { results: number }) {
  return (
    &lt;Drawer
      trigger={
        &lt;Button variant="soft" leftIcon={&lt;SlidersHorizontal /&gt;}&gt;
          Filters
        &lt;/Button&gt;
      }
      title="Filters"
      description="New York · 1,240 listings"
      desktop="dialog"
      closeLabel="Close filters"
      footer={
        &lt;DrawerClose asChild&gt;
          &lt;Button size="lg" fullWidth&gt;
            Show {results} results
          &lt;/Button&gt;
        &lt;/DrawerClose&gt;
      }
    &gt;
      &lt;p className="text-sm text-muted-foreground"&gt;Price, condition and categories&lt;/p&gt;
    &lt;/Drawer&gt;
  );
}
</code></pre>
<blockquote>
<p><strong>Live demo: Drawer.</strong> The original article embeds an interactive demo here (Tap Filters: on a phone the sheet slides up and you can drag it down to close; on a wide screen the same component opens a centered dialog). <a href="https://wingo-ui.com/blog/shadcn-alternatives">Try it live on wingo-ui.com</a> or open the <a href="https://wingo-ui.com/components/drawer">Drawer docs</a>. Install: <code>npx wingo-ui@latest add drawer</code></p>
</blockquote>
<p>On a phone the sheet has a drag handle and no close button; in the desktop dialog a close button appears in the corner, and <code>closeLabel</code> is its accessible name.</p>
<p>Touch targets follow the same split. HeroUI v3 makes its buttons taller below <code>md</code>, as noted above. Wingo UI's <a href="https://wingo-ui.com/components/button">Button</a> grows its medium size from 40px to 44px on coarse pointers and gives the small size a 44px hit area without changing how it looks. For the phone question alone, our <a href="https://wingo-ui.com/blog/best-react-component-library-for-mobile">best React component library for mobile</a> comparison checks nine libraries at 390px, Ionic React and antd-mobile included, on sheets, touch targets, safe areas and gestures.</p>
<h2>Which shadcn alternatives work best with coding agents?</h2>
<p>In October 2026 most of them have an MCP server, so the useful question is what the server can do. We see three kinds:</p>
<ul>
<li><strong>Documentation servers</strong> answer questions about components, props and examples, and npm still installs the package. MUI, Mantine (<code>search_docs</code>, <code>get_item_props</code>, <code>get_api</code>), Chakra UI (<code>list_components</code>, <code>get_component_props</code>, <code>get_theme</code>), Ant Design (eight tools behind <code>antd mcp</code>, <code>antd_changelog</code> among them), React Aria and HeroUI work this way.</li>
<li><strong>Install servers</strong> find items and put their source into your project. The shadcn MCP server does it through the shadcn CLI for any registry in <code>components.json</code>, and Untitled UI's server can browse, search and install its components. Magic UI's gives the agent direct access to its components.</li>
<li><strong>Servers that also check for updates.</strong> Ours answers searches and returns props with defaults, the design rules and the theme tokens without an account, and sends the source to an API key (a free account for free items, Pro for the rest). Its <code>check_updates</code> tool compares the versions in your <code>wingo-ui.json</code> with the latest release, so the agent hears about fixes before it edits a file that has one waiting.</li>
</ul>
<p>Setting each one up takes one line:</p>
<pre><code class="language-bash"># documentation servers: the agent reads props and examples, npm installs the package
claude mcp add mantine -- npx -y @mantine/mcp-server
claude mcp add chakra-ui -- npx -y @chakra-ui/react-mcp

# an install server for every registry in components.json
npx shadcn@latest mcp init --client claude

# the Wingo UI MCP server plus a rules file for the agent
npx wingo-ui@latest agents install claude
</code></pre>
<p>The server matters more for libraries whose source you own. With a package, an agent that guesses a prop gets a type error. With copied source, the agent can edit the component itself, so it needs the real API and the rules behind it. Our <a href="https://wingo-ui.com/blog/component-library-mcp-guide">guide to component library MCP servers</a> covers the tools, the rules file and the setup for Claude Code, Cursor and Codex. If you are choosing between a hosted generator and an agent in your repository, our <a href="https://wingo-ui.com/blog/v0-vs-claude-code-for-ui">v0 vs Claude Code comparison</a> weighs code ownership, design consistency and cost across sign-in, pricing and settings screens.</p>
<h2>How do you update components you copied?</h2>
<p>With a package, you run <code>npm update</code> and read the changelog. With copied source, the merge is yours, and libraries differ in how much they help.</p>
<p>The shadcn CLI can preview: <code>npx shadcn@latest add dialog --dry-run</code> lists what would change and <code>--diff</code> shows the difference for a file. Bringing an upstream fix into a file you have edited is then a manual merge, or a job for your agent with the shadcn skill loaded. Every registry you install through the shadcn CLI behaves the same way. Our guide to <a href="https://wingo-ui.com/blog/update-shadcn-components-without-losing-edits">updating shadcn components without losing edits</a> turns that manual merge into a git 3-way merge.</p>
<p>Wingo UI's CLI records the version of everything it installs in <code>wingo-ui.json</code> and keeps the installed copy in <code>.wingo-ui/base/</code>, which lets it run a three-way merge, the same idea as <code>git merge</code>:</p>
<pre><code class="language-bash">npx wingo-ui@latest outdated              # installed vs latest, and which files you edited
npx wingo-ui@latest diff drawer --base    # what you changed since installing
npx wingo-ui@latest update drawer         # untouched files are replaced, your edits are kept
</code></pre>
<p>When upstream and you changed the same lines, the file gets conflict markers and you choose, or you settle every conflict with <code>--theirs</code> or <code>--ours</code>. The <a href="https://wingo-ui.com/docs/updates">updates docs</a> cover the details. One caveat: files you install through the shadcn CLI from our registry carry no version record, so <code>wingo-ui update</code> cannot merge into them later.</p>
<h2>Which shadcn alternatives include a data table?</h2>
<p>MUI, Ant Design and Wingo UI ship a full data table; shadcn/ui gives you a guide instead. Its <a href="https://ui.shadcn.com/docs/components/data-table">Data Table page</a> explains that one data table component would lose the flexibility of headless UI, so it walks you through building your own on the Table component and TanStack Table v9. That is a good fit if your tables are all different. If you need the same admin table on twenty screens, the guide becomes code you maintain. Our <a href="https://wingo-ui.com/blog/tanstack-table-v9-data-table">TanStack Table v9 data table tutorial</a> builds that code with filters, bulk actions and server pagination, and compares it with the ready-made tables.</p>
<p>The alternatives split three ways. MUI's X Data Grid has a free MIT version and adds its advanced features in the paid plans. Ant Design's Table has sorting, filters and pagination built in, and the other packages give you a styled table to build on. Wingo UI's <a href="https://wingo-ui.com/components/data-table">Data Table</a> is a Pro component on TanStack Table v9 with search, filters, sorting, bulk actions, pinning, resizing and virtualization, and below 768px each row becomes a card instead of a table that scrolls sideways:</p>
<pre><code class="language-tsx">"use client";

import { DataTable, createColumns } from "@/components/ui/data-table";

type Order = { id: string; customer: string; city: string; status: string; total: number };

const col = createColumns&lt;Order&gt;();
const columns = col([
  col.accessor("customer", { header: "Customer", filter: "text", mobile: "title" }),
  col.accessor("city", { header: "City", filter: "multi-select", mobile: "subtitle" }),
  col.accessor("status", {
    header: "Status",
    type: "badge",
    badge: { Paid: { tone: "success" }, Refunded: { tone: "warning" }, Failed: { tone: "danger" } },
    filter: "multi-select",
  }),
  col.accessor("total", { header: "Total", type: "currency", footer: "sum" }),
]);

export function Orders({ orders }: { orders: Order[] }) {
  return (
    &lt;DataTable
      data={orders}
      columns={columns}
      caption="Orders"
      locale="en-US"
      selectionMode="multiple"
      searchPlaceholder="Search orders"
      mobileLayout="cards"
    /&gt;
  );
}
</code></pre>
<blockquote>
<p><strong>Live demo: Data Table.</strong> The original article embeds an interactive demo here (Search for a client, toggle the Overdue chip, then sort or filter from the toolbar; on a phone each row becomes a card with a Load more button). <a href="https://wingo-ui.com/blog/shadcn-alternatives">Try it live on wingo-ui.com</a> or open the <a href="https://wingo-ui.com/components/data-table">Data Table docs</a>. Install: <code>npx wingo-ui@latest add data-table</code></p>
</blockquote>
<p>The <code>mobile</code> key on a column decides where its value goes on the phone card: <code>"title"</code>, <code>"subtitle"</code>, <code>"meta"</code>, <code>"field"</code> or <code>"hidden"</code>. Rows need a unique <code>id</code> (or pass <code>getRowId</code>), because selection uses it. Switch <code>mobileLayout</code> to <code>"scroll"</code> when a table has to stay a table.</p>
<h2>Which shadcn UI alternative for React fits your project?</h2>
<p>Match the library to the job. These are our picks, including the many cases where the answer is not ours:</p>
<ul>
<li><strong>Enterprise admin with heavy grids and date ranges:</strong> MUI with MUI X, the deepest set of data components in this list.</li>
<li><strong>Internal tools with complex forms, without Tailwind:</strong> Mantine. Free, MIT, with forms and dates built in.</li>
<li><strong>A design system of your own:</strong> Base UI or React Aria Components. React Aria if touch and screen reader behavior has to be exact.</li>
<li><strong>A free shadcn alternative on the new default base:</strong> coss ui.</li>
<li><strong>An npm package with the shadcn look and stack:</strong> HeroUI 3.</li>
<li><strong>Animated marketing pages:</strong> Magic UI or Aceternity UI, next to whatever you use for the app.</li>
<li><strong>Server-rendered pages with little JavaScript, or a stack beyond React:</strong> daisyUI.</li>
<li><strong>A mobile-only web app, or one wrapped in Capacitor, with a native iOS or Material look:</strong> Konsta UI.</li>
<li><strong>A product that people use on phones and desktops, built with coding agents, where you own the source and still want updates:</strong> Wingo UI.</li>
</ul>
<h2>When should you stay on shadcn/ui?</h2>
<p>Stay when your app is used mostly on desktops, your team is happy to write its own responsive wrappers, and the biggest ecosystem matters more to you than any one feature. shadcn/ui is free and MIT licensed, its <a href="https://github.com/shadcn-ui/ui">GitHub repository</a> had about 125,000 stars on October 8, 2026, it supports three bases, and its CLI installs from a whole directory of community registries. Switching has a cost: every call site that uses a component's API changes, and a new library brings its own conventions for tokens, sizes and states.</p>
<p>You also do not have to choose. Because registries share one CLI, the realistic path for most teams is shadcn/ui as the base plus one or two registries for what it lacks.</p>
<h2>How do you switch without a rewrite?</h2>
<p>Replace one component at a time, starting with the ones that hurt most, and keep both libraries in the project while you do.</p>
<p>Three practical points for a move from shadcn/ui to Wingo UI, most of which apply to any registry:</p>
<ol>
<li><strong>File names collide.</strong> Both libraries write <code>components/ui/button.tsx</code>, <code>dialog.tsx</code> and <code>drawer.tsx</code>. Our CLI keeps an existing file that differs and tells you to rerun with <code>--overwrite</code>, so commit first and swap deliberately.</li>
<li><strong>Call sites change.</strong> shadcn/ui's Button uses <code>variant="destructive"</code> and <code>variant="secondary"</code>; ours separates the role from the color, so those become <code>tone="danger"</code> and <code>variant="soft"</code>.</li>
<li><strong>Pick the right CLI for a Base UI project.</strong> Free items install from their URL with <code>npx shadcn@latest add https://wingo-ui.com/r/button.json</code>, and Pro items through the <code>@wingo-ui</code> registry in <code>components.json</code>. But shadcn projects created with a Base UI style rewrite <code>asChild</code>, which our components rely on, so in those projects use <code>npx wingo-ui@latest add</code> instead, as the <a href="https://wingo-ui.com/docs/installation">installation docs</a> explain.</li>
</ol>
<p>To keep the new components close to your current look while you migrate, set app-wide defaults once instead of passing props everywhere. Our Button and Input default to <code>radius="md"</code>, which gives the medium size 10px corners; if your current controls use 8px, <code>"sm"</code> matches them:</p>
<pre><code class="language-tsx">import { UIProvider } from "@/lib/ui-config";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    &lt;html lang="en"&gt;
      &lt;body&gt;
        &lt;UIProvider defaults={{ button: { radius: "sm" }, input: { radius: "sm" } }}&gt;
          {children}
        &lt;/UIProvider&gt;
      &lt;/body&gt;
    &lt;/html&gt;
  );
}
</code></pre>
<p><code>UIProvider</code> is a client component, so the root layout can stay a server component. Explicit props still win over these defaults, and the defaults win over the component's own. The Button below runs with the soft variant and a full radius set through props, so you can compare it with your current buttons in both themes.</p>
<blockquote>
<p><strong>Live demo: Button.</strong> The original article embeds an interactive demo here (Press the button: it dips on a quick spring and settles back; Tab to it for the focus ring, then switch the theme to compare light and dark). <a href="https://wingo-ui.com/blog/shadcn-alternatives">Try it live on wingo-ui.com</a> or open the <a href="https://wingo-ui.com/components/button">Button docs</a>. Install: <code>npx wingo-ui@latest add button</code></p>
</blockquote>
<h2>Where should you start?</h2>
<p>Pick the one component that hurts most on a phone and try an alternative for that component alone. If it is an overlay, install the free Drawer with <code>npx wingo-ui@latest add drawer</code> (no account needed; it brings the Button and the media query hook along), put it behind one trigger people use on phones, and open it on your own device. The Data Table and the rest of the catalog are part of <a href="https://wingo-ui.com/pricing">Wingo UI Pro</a>, and the <a href="https://wingo-ui.com/docs/mcp">MCP server</a> answers questions about every item without an account, so your agent can read the props before you decide.</p>
<h2>Components in this post</h2>
<ul>
<li><a href="https://wingo-ui.com/components/button">Button</a> (Free): The button every screen starts with: five variants, six tones, three sizes plus icon sizes, and a loading state that never jumps. Install: <code>npx wingo-ui@latest add button</code></li>
<li><a href="https://wingo-ui.com/components/drawer">Drawer</a> (Free): The library's bottom sheet on vaul: drag to dismiss, snap points, nesting, and a side panel or dialog on desktop. Install: <code>npx wingo-ui@latest add drawer</code></li>
<li><a href="https://wingo-ui.com/components/data-table">Data Table</a> (Pro): The admin table on TanStack Table v9: search, filters, sorting, bulk actions, pinning, resizing, virtualization and cards on phones. Install: <code>npx wingo-ui@latest add data-table</code></li>
</ul>
<h2>FAQ</h2>
<h3>What is the best free alternative to shadcn/ui?</h3>
<p>If you want to keep owning the source, coss ui is the closest match: MIT licensed, built on Base UI and installed through the shadcn CLI. If you would rather install a package, Mantine is MIT licensed, free and covers forms, dates and charts as well as the basic components.</p>
<h3>Is shadcn/ui still built on Radix?</h3>
<p>Not by default. Since July 2026, npx shadcn init starts new projects on Base UI, Radix stays fully supported with the -b radix flag, and React Aria is available as a third base with --base aria.</p>
<h3>Which shadcn alternative works best on mobile?</h3>
<p>Konsta UI if you want an iOS or Material look for a mobile-only web app, and Wingo UI if the same app runs on desktops and phones, because its popover, select, menus and dialog open as draggable bottom sheets below 768px by default. HeroUI, Chakra UI and daisyUI can move a modal or drawer to the bottom of the screen on phones; shadcn/ui, MUI and Mantine document a recipe you wire per component.</p>
<h3>Can I use a shadcn alternative alongside shadcn/ui?</h3>
<p>Yes. Registries such as coss ui, Magic UI, Aceternity UI and Wingo UI install through the same shadcn add command, so you can replace one component at a time. Watch for files with the same name, such as components/ui/button.tsx, and commit before each swap.</p>
<h3>Do shadcn alternatives work with Claude Code and Cursor?</h3>
<p>Most do. As of October 2026 shadcn/ui, MUI, Mantine, Chakra UI, HeroUI, Ant Design, React Aria, Magic UI, Untitled UI and Wingo UI publish MCP servers, and nearly all publish llms.txt. The servers differ: some answer documentation questions, some install source files, and Wingo UI's also checks your installed versions for updates.</p>
<hr />
<p><em>This article was originally published on the Wingo UI blog: <a href="https://wingo-ui.com/blog/shadcn-alternatives">14 shadcn Alternatives in 2026: Price, Mobile and MCP</a>. The original has the live, interactive demos and is kept up to date.</em></p>
]]></content:encoded></item><item><title><![CDATA[Mobile-First React Components: 9 Rules With Code]]></title><description><![CDATA[Nine rules for mobile-first React components: bottom sheets, 44px targets, safe areas, dvh, keyboard-aware bars, tables as cards.]]></description><link>https://wingo-ui.hashnode.dev/mobile-first-react-components-9-rules-with-code</link><guid isPermaLink="true">https://wingo-ui.hashnode.dev/mobile-first-react-components-9-rules-with-code</guid><category><![CDATA[React]]></category><category><![CDATA[Web Development]]></category><category><![CDATA[Mobile Development]]></category><dc:creator><![CDATA[Serban Dan Rusu]]></dc:creator><pubDate>Mon, 19 Oct 2026 23:54:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ad6a430d0d0b34960b508b1/7014f479-166e-4f7d-8eb9-f70b49e00523.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2>TL;DR</h2>
<p>Mobile-first React components are designed for a thumb on a 390px screen first and enhanced for a mouse after. The nine rules: open popovers, selects, menus and dialogs as bottom sheets below 768px; make targets 44px on coarse pointers; never hide a control behind hover; pad fixed bars with the safe-area insets; size full-height layouts in dvh, not vh; keep bottom bars out of the on-screen keyboard; turn table rows into cards; give every gesture a visible alternative; and switch layout with CSS breakpoints. Keep media query hooks for behavior only, because the server does not know the screen width.</p>
<p>Open a typical React app on a phone and the cracks show in the same places: a dropdown menu hanging off the edge of the screen, a 32px icon button you have to aim for, a checkout bar hidden behind the home indicator, a data table you scroll sideways to read one row. Mobile-first React components fix those problems once, inside the component, so every screen that uses them gets them right without a media query in the page. This is the rulebook we follow in Wingo UI, with the reason and the code for each rule. We build Wingo UI, so we are not neutral; outside facts link to their sources and are dated.</p>
<h2>What makes a React component mobile-first?</h2>
<p>A mobile-first component is designed for a thumb on a 360 to 390px screen with an on-screen keyboard, and then enhanced for a mouse and a wide window. Beyond reflowing its layout, the component changes how it behaves: which overlay it opens, how big its hit area is, what replaces hover, and where it sits relative to the notch and the keyboard.</p>
<p>The nine rules in one table, then a section for each:</p>
<table>
<thead>
<tr>
<th>Rule</th>
<th>On a phone</th>
<th>Why</th>
</tr>
</thead>
<tbody><tr>
<td>Overlays</td>
<td>Popovers, selects, menus and dialogs open as bottom sheets below 768px</td>
<td>An anchored panel has no room and sits under the thumb</td>
</tr>
<tr>
<td>Touch targets</td>
<td>44px on coarse pointers, 8px apart</td>
<td>A finger is less precise than a cursor</td>
</tr>
<tr>
<td>Hover</td>
<td>Nothing is hover-only</td>
<td>Touch screens have no hover</td>
</tr>
<tr>
<td>Safe areas</td>
<td>Fixed bars pad <code>env(safe-area-inset-*)</code></td>
<td>The notch and the home indicator cover content</td>
</tr>
<tr>
<td>Height</td>
<td><code>dvh</code> or <code>svh</code>, never <code>vh</code></td>
<td><code>100vh</code> is taller than the visible screen</td>
</tr>
<tr>
<td>Keyboard</td>
<td>Bottom bars hide or ride above it, inputs use 16px text</td>
<td>Fixed bars end up behind the keyboard</td>
</tr>
<tr>
<td>Tables</td>
<td>Rows become cards</td>
<td>Sideways scrolling hides most of every row</td>
</tr>
<tr>
<td>Gestures</td>
<td>Every gesture has a visible alternative</td>
<td>Gestures are invisible, and not everyone can perform them</td>
</tr>
<tr>
<td>Layout and behavior</td>
<td>CSS breakpoints for layout, a hook for behavior</td>
<td>The server does not know the screen width</td>
</tr>
</tbody></table>
<p>If you are building a React mobile first UI from scratch, apply each rule in every component before adding the next one: people learn an interface from its most common pattern.</p>
<h2>Why should a popover become a bottom sheet on a phone?</h2>
<p>A popover anchored to a small trigger has nowhere good to go on a 390px screen, while a bottom sheet opens where the thumb already is. The finger that tapped the trigger covers it, the panel gets clipped or flipped above the trigger, and a tap that lands just outside it closes it. A sheet takes the full width, has room for 48px rows, can be dragged away, and keeps its actions in the bottom part of the screen, the part a thumb reaches without changing grip.</p>
<p>We render a bottom sheet below 768px for the popover, select, dropdown menu, context menu and dialog, by default. The exceptions matter as much as the rule:</p>
<ul>
<li>Tooltips never become sheets. Nothing hovers on a touch screen, so the text goes inline or behind a long press.</li>
<li>Alert dialogs stay centered at every width. A short question that must be answered reads like an iOS alert.</li>
<li>Suggestions under a text field stay attached to it, because a sheet would cover what you type, as in the <a href="https://wingo-ui.com/components/mention-input">Mention Input</a>. The <a href="https://wingo-ui.com/components/combobox">Combobox</a> takes the other route: a full-height sheet with its own search box.</li>
</ul>
<p>In Wingo UI the switch is a <code>responsive</code> prop that defaults to <code>true</code> on the <a href="https://wingo-ui.com/components/popover">Popover</a>, <a href="https://wingo-ui.com/components/select">Select</a>, <a href="https://wingo-ui.com/components/dropdown-menu">Dropdown Menu</a>, <a href="https://wingo-ui.com/components/dialog">Dialog</a> and the date pickers. You write the component once:</p>
<pre><code class="language-tsx">"use client";

import { ChevronDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Popover, PopoverClose } from "@/components/ui/popover";

export function PriceFilter({ onApply }: { onApply: () =&gt; void }) {
  return (
    &lt;Popover
      trigger={
        &lt;Button variant="soft" rightIcon={&lt;ChevronDown /&gt;}&gt;
          Price
        &lt;/Button&gt;
      }
      title="Price"
      description="Pick a range or type your own."
      footer={
        &lt;PopoverClose asChild&gt;
          &lt;Button size="sm" onClick={onApply}&gt;
            Apply
          &lt;/Button&gt;
        &lt;/PopoverClose&gt;
      }
    &gt;
      &lt;p className="text-muted-foreground"&gt;Between $500 and $1,500&lt;/p&gt;
    &lt;/Popover&gt;
  );
}
</code></pre>
<p>On a desktop that is a floating panel with a right-aligned Apply button. Below 768px the same JSX opens a bottom sheet with a handle and a full-width 48px Apply button. Pass <code>responsive={false}</code> when a panel has to stay anchored to a field.</p>
<p>The sheet underneath is the free <a href="https://wingo-ui.com/components/drawer">Drawer</a>, built on vaul. It follows your finger, closes when you let go past a quarter of its height or flick it, stops at snap points, and lets a long list scroll first and drag the sheet only once the list is back at the top. Opening it moves focus to the sheet itself instead of the first field, so the keyboard does not jump up over half the content. From 768px it stays a centered sheet, or becomes a side panel or a dialog, depending on the <code>desktop</code> prop.</p>
<blockquote>
<p><strong>Live demo: Drawer.</strong> The original article embeds an interactive demo here (Tap Filters: on a phone the sheet slides up and you can drag it down to close; on a wide screen this one opens as a side panel). <a href="https://wingo-ui.com/blog/mobile-first-react-components">Try it live on wingo-ui.com</a> or open the <a href="https://wingo-ui.com/components/drawer">Drawer docs</a>. Install: <code>npx wingo-ui@latest add drawer</code></p>
</blockquote>
<p>The demo is a listings page: Filters opens a draggable bottom sheet below 768px and a panel from the right edge on wider screens, with the result count on a pinned button in both.</p>
<p>Two posts go deeper on this rule. The <a href="https://wingo-ui.com/blog/react-bottom-sheet-for-the-web">React bottom sheet guide</a> covers snap points, drag to dismiss, nested sheets and the keyboard, and the <a href="https://wingo-ui.com/blog/shadcn-responsive-dialog-on-mobile">shadcn responsive dialog tutorial</a> builds the same card-to-sheet switch on stock shadcn/ui.</p>
<h2>How do you switch between a sheet and a dialog without a hydration flash?</h2>
<p>Use CSS breakpoints for anything visible on the first paint, and a media query hook only for behavior that starts after an interaction, such as which overlay opens. The server has no screen, so any hook that reads <code>matchMedia</code> has to return a guess during server rendering and hydration. When that guess drives visible layout, phones see the desktop layout first and then a jump.</p>
<p>The <code>use-mobile</code> hook that ships with shadcn/ui's sidebar <a href="https://github.com/shadcn-ui/ui/blob/main/apps/v4/registry/new-york-v4/hooks/use-mobile.ts">starts as <code>undefined</code>, returns <code>false</code>, and flips in an effect</a> (as of October 2026). Our free <a href="https://wingo-ui.com/components/use-media-query">useMediaQuery</a> uses the same 768px breakpoint, reads it through <code>useSyncExternalStore</code> and makes the server value a parameter. This is the core of it:</p>
<pre><code class="language-ts">import { useCallback, useSyncExternalStore } from "react";

export function useMediaQuery(query: string, serverFallback = false) {
  const subscribe = useCallback(
    (onChange: () =&gt; void) =&gt; {
      const list = window.matchMedia(query);
      list.addEventListener("change", onChange);
      return () =&gt; list.removeEventListener("change", onChange);
    },
    [query],
  );
  return useSyncExternalStore(
    subscribe,
    () =&gt; window.matchMedia(query).matches,
    // the server and the hydration render
    () =&gt; serverFallback,
  );
}

export const useIsMobile = () =&gt; useMediaQuery("(max-width: 767.98px)");
</code></pre>
<p>Both hooks say <code>false</code> on the server. That is harmless for overlays, because an overlay is closed during server rendering: by the time someone taps the trigger, the hook holds the real value. It is harmful for content:</p>
<pre><code class="language-tsx">// wrong: the server renders the table, phones swap to cards after hydration
const isMobile = useIsMobile();
return isMobile ? &lt;InvoiceCards rows={rows} /&gt; : &lt;InvoiceTable rows={rows} /&gt;;

// right: both are in the HTML and CSS picks one before the first paint
return (
  &lt;&gt;
    &lt;InvoiceTable rows={rows} className="hidden md:table" /&gt;
    &lt;InvoiceCards rows={rows} className="md:hidden" /&gt;
  &lt;/&gt;
);
</code></pre>
<p>This is the part of mobile first design React apps get wrong without noticing, because the wrong version looks fine when you drag a desktop window narrow after the page has loaded. The jump only shows when a phone loads the server-rendered page. Our <a href="https://wingo-ui.com/blog/react-use-mobile-hook-ssr">guide to an SSR-safe React use mobile hook</a> tests three common versions of the hook through hydration and sorts which jobs belong to CSS.</p>
<h2>How big should touch targets be?</h2>
<p>Make every target 44 by 44 CSS pixels on touch screens and keep at least 8px between neighbors. The standards disagree on the exact number; 44px meets the strictest web criterion (WCAG level AAA) and Apple's default control size:</p>
<table>
<thead>
<tr>
<th>Source (as of October 2026)</th>
<th>Size</th>
<th>Status</th>
</tr>
</thead>
<tbody><tr>
<td><a href="https://www.w3.org/WAI/WCAG22/Understanding/target-size-minimum.html">WCAG 2.2, 2.5.8 Target Size (Minimum)</a></td>
<td>24 by 24 CSS px, or enough spacing</td>
<td>Level AA</td>
</tr>
<tr>
<td><a href="https://www.w3.org/WAI/WCAG22/Understanding/target-size-enhanced.html">WCAG 2.2, 2.5.5 Target Size (Enhanced)</a></td>
<td>44 by 44 CSS px</td>
<td>Level AAA</td>
</tr>
<tr>
<td><a href="https://developer.apple.com/design/human-interface-guidelines/accessibility">Apple Human Interface Guidelines</a></td>
<td>44 by 44 pt default on iOS, 28 by 28 pt minimum</td>
<td>Guideline</td>
</tr>
<tr>
<td><a href="https://developer.android.com/guide/topics/ui/accessibility/apps">Android accessibility guide</a></td>
<td>48 by 48 dp</td>
<td>Recommendation</td>
</tr>
</tbody></table>
<p>The catch is the desktop. A 44px button everywhere makes a toolbar or a settings page look bloated next to a mouse pointer. Tailwind CSS v4 has a <a href="https://tailwindcss.com/docs/hover-focus-and-other-states"><code>pointer-coarse</code> variant</a> that compiles to <code>@media (pointer: coarse)</code>, so the size can follow the input device instead of the screen width. A tablet and a phone both get the bigger targets; a laptop with a trackpad does not.</p>
<p>For a text button the size is <code>h-10 pointer-coarse:h-11</code>: 40px with a mouse, 44px under a finger. An icon button that must stay small gets an invisible hit area instead:</p>
<pre><code class="language-tsx">import { Ellipsis } from "lucide-react";

// looks 32px, takes 44px taps on touch
export function MoreButton() {
  return (
    &lt;button
      type="button"
      aria-label="More actions"
      className="relative grid size-8 touch-manipulation place-items-center [-webkit-tap-highlight-color:transparent] pointer-coarse:after:absolute pointer-coarse:after:-inset-1.5"
    &gt;
      &lt;Ellipsis className="size-4" /&gt;
    &lt;/button&gt;
  );
}
</code></pre>
<p>The <code>::after</code> trick adds 6px on every side of the 32px button without moving anything. <code>touch-manipulation</code> turns off double-tap zoom on the control, so taps register without the browser waiting for a second one, and the transparent tap highlight removes the gray box mobile browsers paint on a tapped element. Both live on the component itself, because a component copied into another project does not bring your global stylesheet along. The <a href="https://wingo-ui.com/components/button">Button</a> in Wingo UI is built this way: <code>md</code> is 40px with a mouse and 44px on touch, <code>sm</code> and <code>icon-sm</code> stay 32px and get the invisible hit area, and <code>lg</code> is 48px everywhere. The <a href="https://wingo-ui.com/blog/minimum-touch-target-size-react-tailwind">minimum touch target size guide</a> compares what WCAG, Apple and Android require and shows how to test the hit areas.</p>
<h2>Why do hover effects break on phones?</h2>
<p>Touch screens have no hover, so anything that only appears on hover does not exist on a phone. In Tailwind CSS v4 the <code>hover:</code> variant <a href="https://tailwindcss.com/docs/upgrade-guide">only applies when the primary input can hover</a> (<code>@media (hover: hover)</code>). That fixes the old sticky hover state after a tap, and it also means a hover-only control never shows up on touch at all.</p>
<p>The classic case is a row action that fades in when the pointer is over the row. The fix is a focus variant and a coarse-pointer variant:</p>
<pre><code class="language-tsx">import { Ellipsis } from "lucide-react";

export function ClientRow({ name }: { name: string }) {
  return (
    &lt;li className="group/row flex h-12 items-center justify-between gap-3 px-4"&gt;
      {name}
      &lt;button
        type="button"
        aria-label={`Actions for ${name}`}
        className="grid size-8 place-items-center opacity-0 transition-opacity group-hover/row:opacity-100 group-focus-within/row:opacity-100 pointer-coarse:opacity-100"
      &gt;
        &lt;Ellipsis className="size-4" /&gt;
      &lt;/button&gt;
    &lt;/li&gt;
  );
}
</code></pre>
<p>Hover reveals it for a mouse, focus reveals it for a keyboard, and on a coarse pointer it is always there. The <a href="https://wingo-ui.com/components/data-table">Data Table</a> puts the same classes on the wrapper of its row menu trigger, plus <code>has-[[data-state=open]]:opacity-100</code>: without it, the trigger fades out while its menu is open, as soon as the pointer moves from the row into the menu. The other two replacements for hover are a tap and a long press: a hover card opens as a sheet on the first tap, a tooltip shows on a long press, and a context menu opens as an action sheet when you hold a row.</p>
<h2>How do you keep fixed bars clear of the notch and the home indicator?</h2>
<p>Set <code>viewport-fit=cover</code>, then pad every element pinned to a screen edge with <code>env(safe-area-inset-*)</code>, with a minimum for devices that report zero. <code>cover</code> scales the viewport to fill the whole display, notch and home indicator included, and <a href="https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/meta/name/viewport">MDN highly recommends</a> the safe area inset variables with it so important content stays visible.</p>
<p>In the Next.js App Router the viewport meta tag comes from a <code>viewport</code> export in the root layout:</p>
<pre><code class="language-tsx">// app/layout.tsx
import type { Viewport } from "next";

export const viewport: Viewport = {
  width: "device-width",
  initialScale: 1,
  viewportFit: "cover",
};
</code></pre>
<p>Then pad with an arbitrary value: <code>pb-[max(env(safe-area-inset-bottom),12px)]</code>. A plain <code>pb-[env(safe-area-inset-bottom)]</code> leaves buttons touching the screen edge on devices without a home indicator, where the inset is 0; <code>max()</code> keeps a little air.</p>
<p>The harder problem is two bars at once. A product page on a phone often has a tab bar at the bottom and a buy bar above it, and both want the safe area. The <a href="https://wingo-ui.com/components/bottom-tab-bar">Bottom Tab Bar</a> pads the home indicator and writes its height to a <code>--bottom-bar-offset</code> CSS variable. The <a href="https://wingo-ui.com/components/sticky-cta-bar">Sticky CTA Bar</a> sits at <code>bottom: var(--bottom-bar-offset)</code> and pads only the part of the safe area the tab bar does not already cover, so there is no double gap:</p>
<pre><code class="language-tsx">"use client";

import { House, Search, ShoppingBag, UserRound } from "lucide-react";
import { BottomTabBar, type BottomTabBarItem } from "@/components/ui/bottom-tab-bar";
import { StickyCtaBar } from "@/components/ui/sticky-cta-bar";

const tabs: BottomTabBarItem[] = [
  { label: "Home", href: "/", icon: &lt;House /&gt; },
  { label: "Search", href: "/search", icon: &lt;Search /&gt; },
  { label: "Cart", href: "/cart", icon: &lt;ShoppingBag /&gt;, badge: 2 },
  { label: "Account", href: "/account", icon: &lt;UserRound /&gt; },
];

export function ProductChrome({ onAddToCart }: { onAddToCart: () =&gt; Promise&lt;void&gt; }) {
  return (
    &lt;&gt;
      &lt;StickyCtaBar
        price={349}
        compareAtPrice={449}
        locale="en-US"
        actionLabel="Add to cart"
        tone="brand"
        hideFrom="md"
        onAction={onAddToCart}
      /&gt;
      &lt;BottomTabBar items={tabs} className="md:hidden" hideOnScroll /&gt;
    &lt;/&gt;
  );
}
</code></pre>
<p>The tab bar takes 3 to 5 tabs, each far larger than 44px. With <code>hideOnScroll</code> it follows your finger out of view on the way down and comes back on the way up, moved by a motion value so nothing re-renders per frame. The buy bar shows a spinner while the <code>onAction</code> promise runs without changing its width. The <a href="https://wingo-ui.com/blog/tailwind-safe-area-insets">Tailwind safe area insets tutorial</a> covers the utilities, a FAB stacked above the tab bar and where toasts go, and the <a href="https://wingo-ui.com/blog/react-bottom-navigation-bar">React bottom navigation bar guide</a> covers badges, the center action and when a sidebar fits better.</p>
<blockquote>
<p><strong>Live demo: Bottom Tab Bar.</strong> The original article embeds an interactive demo here (Tap the tabs, then tap the active one again to jump back to the top; the raised Add button opens a sheet). <a href="https://wingo-ui.com/blog/mobile-first-react-components">Try it live on wingo-ui.com</a> or open the <a href="https://wingo-ui.com/components/bottom-tab-bar">Bottom Tab Bar docs</a>. Install: <code>npx wingo-ui@latest add bottom-tab-bar</code></p>
</blockquote>
<p>The demo is a small CRM with four tabs, an unread count on Inbox and a raised Add action. Open a client, then tap Clients again: the tab goes back to its first screen, the way iOS tab bars do.</p>
<h2>Should you use 100vh, dvh or svh on mobile?</h2>
<p>Use <code>dvh</code> for app shells and sheets, <code>svh</code> for blocks that must not change height while scrolling, and never <code>vh</code>. <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/length">MDN defines</a> <code>vh</code> as equal to <code>lvh</code>, the large viewport measured with the browser toolbars retracted. While the address bar is showing, <code>100vh</code> (Tailwind's <code>h-screen</code>) is taller than the visible area, and the bottom of your layout, often the button that matters, sits under the toolbar.</p>
<p><code>dvh</code> follows the toolbars as they expand and retract, which is what a full-height shell wants. MDN also warns that dynamic units can resize content while the user scrolls, so a hero section sized in <code>dvh</code> may twitch; <code>svh</code> is the stable choice there. Tailwind CSS v4 ships <code>h-dvh</code>, <code>min-h-dvh</code>, <code>h-svh</code> and <code>h-lvh</code>. In Wingo UI the <a href="https://wingo-ui.com/components/app-shell">App Shell</a> uses <code>min-h-dvh</code> and bottom sheets cap at <code>max-h-[92dvh]</code>, so a tall sheet keeps a strip of the page visible above it whatever the toolbars do. The <a href="https://wingo-ui.com/blog/100vh-mobile-dvh-tailwind">100vh mobile fix</a> compares the three units in detail and builds both kinds of full-height shell.</p>
<h2>What happens to bottom bars when the on-screen keyboard opens?</h2>
<p>They end up behind the keyboard unless you move them. Since <a href="https://developer.chrome.com/blog/viewport-resize-behavior">Chrome 108 on Android</a>, the keyboard resizes only the visual viewport, which the Chrome team describes as matching Safari on iOS. An element with <code>position: fixed; bottom: 0</code> is placed against the layout viewport, which still reaches the bottom of the screen, behind the keyboard.</p>
<p>There are two good answers. Hide the bar while a field has focus, which suits a checkout bar with a coupon field (the Sticky CTA Bar's default, <code>keyboard="hide"</code>). Or lift it by the height the keyboard covers, which suits a chat composer (<code>keyboard="lift"</code>). The lift comes from the visual viewport: the covered height is <code>innerHeight - visualViewport.height - visualViewport.offsetTop</code> while a text field has focus. The <a href="https://wingo-ui.com/components/use-keyboard-inset">useKeyboardInset</a> hook (Pro) measures it at most once per frame and exposes it as a motion value, so the bar moves without a re-render:</p>
<pre><code class="language-tsx">"use client";

import { motion, useTransform } from "motion/react";
import { useKeyboardInset } from "@/hooks/use-keyboard-inset";

export function Composer() {
  const { inset } = useKeyboardInset();
  const y = useTransform(inset, (value) =&gt; -value);
  return (
    &lt;motion.form style={{ y }} className="fixed inset-x-0 bottom-0 bg-background px-4 pt-2 pb-[max(env(safe-area-inset-bottom),12px)]"&gt;
      &lt;input
        aria-label="Message"
        inputMode="text"
        enterKeyHint="send"
        autoComplete="off"
        className="h-11 w-full rounded-full bg-foreground/5 px-4 text-base md:text-sm"
      /&gt;
    &lt;/motion.form&gt;
  );
}
</code></pre>
<p>Two more keyboard rules. Give text inputs 16px text on phones (<code>text-base md:text-sm</code>): iOS Safari zooms the page into any focused field with smaller text and does not zoom back out. And set <code>inputMode</code>, <code>enterKeyHint</code> and <code>autoComplete</code> on every field, so the phone shows the right keyboard, the right return key label and the right autofill.</p>
<h2>How should a data table work on a phone?</h2>
<p>Below 768px, turn every row into a card: the primary field as the title, a status badge or an amount in the corner, two to four secondary fields as label and value pairs, and the row actions in a menu that opens as a sheet. Horizontal scrolling is acceptable only when the job is comparing columns, like a spreadsheet or a pricing matrix. For a list of invoices or clients it hides most of every row and makes people scroll in two directions to read one record.</p>
<p>The <a href="https://wingo-ui.com/components/data-table">Data Table</a> does this by default (<code>mobileLayout="cards"</code>). Each column can say where it goes on the card with a <code>mobile</code> role, and the table guesses sensibly when you do not (the first column is the title, the first badge or else the last number is the meta):</p>
<pre><code class="language-tsx">"use client";

import { Pencil } from "lucide-react";
import { DataTable, createColumns } from "@/components/ui/data-table";

type Invoice = { id: string; client: string; city: string; status: string; amount: number };

const col = createColumns&lt;Invoice&gt;();
const columns = col([
  col.accessor("client", { header: "Client", mobile: "title" }),
  col.accessor("city", { header: "City", mobile: "subtitle" }),
  col.accessor("status", {
    header: "Status",
    type: "badge",
    badge: { Paid: { tone: "success" }, Overdue: { tone: "danger" }, Draft: { tone: "neutral" } },
    mobile: "meta",
  }),
  col.accessor("amount", { header: "Amount", type: "currency" }),
  col.accessor("id", { header: "Invoice", mobile: "hidden" }),
]);

export function Invoices({ invoices }: { invoices: Invoice[] }) {
  return (
    &lt;DataTable
      data={invoices}
      columns={columns}
      caption="Invoices"
      locale="en-US"
      selectionMode="multiple"
      rowActions={(row) =&gt; [{ label: "Edit", icon: &lt;Pencil /&gt;, onSelect: () =&gt; console.log(row.id) }]}
    /&gt;
  );
}
</code></pre>
<p>On a phone you long press a card to start selecting, tap to add more, and the bulk actions float at the bottom. Sorting, filters and the row menu open as bottom sheets, and Load more appends rows instead of a pager with tiny page numbers. The table and the cards are both in the server HTML, hidden and shown with breakpoints, so a phone never sees the table first. The <a href="https://wingo-ui.com/blog/tanstack-table-v9-data-table">TanStack Table v9 data table tutorial</a> builds filters, bulk actions and server pagination on TanStack directly, along with the phone layout.</p>
<blockquote>
<p><strong>Live demo: Data Table.</strong> The original article embeds an interactive demo here (On a phone every row is a card you can long press to select; on a wide screen you get the full table with sorting and column menus). <a href="https://wingo-ui.com/blog/mobile-first-react-components">Try it live on wingo-ui.com</a> or open the <a href="https://wingo-ui.com/components/data-table">Data Table docs</a>. Install: <code>npx wingo-ui@latest add data-table</code></p>
</blockquote>
<p>The demo is a client list with search, quick filters, status badges and balances. Below 768px each row becomes a card with the client name as the title and the status in the corner. Which columns make it onto a card, and how sorting and bulk actions work without headers, are in the <a href="https://wingo-ui.com/blog/responsive-table-mobile-cards">responsive table mobile guide</a>.</p>
<h2>Which gestures belong in a mobile web app?</h2>
<p>Drag to dismiss, pull to refresh, swipe actions on rows and hold to confirm all belong, as long as each has a visible alternative and leaves native scrolling alone. Gestures are invisible until someone shows you, and people using a keyboard, a switch or a screen reader cannot perform them at all.</p>
<ul>
<li><strong>Always a button path.</strong> Sheets close with Escape, a tap outside or a close button. Pull to refresh pairs with a refresh button. Swipe actions on a row also live in its menu.</li>
<li><strong><code>touch-action</code> on draggable surfaces.</strong> <code>touch-pan-y</code> on something you swipe sideways keeps vertical scrolling native; <code>touch-none</code> is for surfaces you drag freely, like a signature pad or a slider thumb.</li>
<li><strong><code>overscroll-behavior: contain</code> on scroll containers.</strong> It stops a scroll inside a sheet from chaining into the page, and keeps the browser's own pull to refresh from fighting yours.</li>
<li><strong>Motion values, not state.</strong> A drag that calls <code>setState</code> on every pointer move re-renders on every frame. Write the offset to a motion value and only commit state when the gesture ends.</li>
</ul>
<p>Sortable lists and boards are where these rules collide with native scrolling; the <a href="https://wingo-ui.com/blog/dnd-kit-mobile-touch-drag">dnd kit mobile tutorial</a> sets up touch sensors that let a swipe still scroll the page.</p>
<p>The <a href="https://wingo-ui.com/components/pull-to-refresh">Pull To Refresh</a> shows where the rules bend. It sets <code>overscroll-behavior-y: contain</code> on its scroller and moves the content with a motion value under rubberband resistance. It cannot use <code>touch-action</code>, because the list it wraps must keep scrolling natively, so it listens to <code>touchmove</code> with <code>passive: false</code> and cancels the scroll only for a downward pull at the very top. It ticks when the pull arms, spins until your promise settles (500ms at least, so it never flashes), and exposes the same refresh as an API for a button (the <a href="https://wingo-ui.com/blog/react-pull-to-refresh">React pull to refresh guide</a> covers the indicator styles, accessibility and long virtualized lists):</p>
<pre><code class="language-tsx">"use client";

import { useState, type ReactNode } from "react";
import { RotateCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { PullToRefresh, type PullToRefreshApi } from "@/components/ui/pull-to-refresh";

export function Feed({ reload, children }: { reload: () =&gt; Promise&lt;void&gt;; children: ReactNode }) {
  const [api, setApi] = useState&lt;PullToRefreshApi&gt;();
  return (
    &lt;div className="flex h-dvh flex-col"&gt;
      &lt;header className="flex h-14 items-center justify-end px-4"&gt;
        &lt;Button variant="ghost" size="icon" radius="full" aria-label="Reload" onClick={() =&gt; api?.refresh()}&gt;
          &lt;RotateCw /&gt;
        &lt;/Button&gt;
      &lt;/header&gt;
      &lt;PullToRefresh scroll="self" className="min-h-0 flex-1" setApi={setApi} onRefresh={reload}&gt;
        {children}
      &lt;/PullToRefresh&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<p>Hold to confirm is the gesture for actions that deserve more than a tap and less than a dialog, like deleting a client. The <a href="https://wingo-ui.com/components/haptic-button">Haptic Button</a> in <code>mode="hold"</code> fills while you hold a finger, the mouse button, Space or Enter, and runs <code>onConfirm</code> when the fill completes. Screen readers and voice control, which activate with a plain click and cannot hold, get a two-step path instead: the first activation announces "Activate again to confirm" and a second one within 4 seconds confirms.</p>
<pre><code class="language-tsx">"use client";

import { Trash2 } from "lucide-react";
import { HapticButton } from "@/components/ui/haptic-button";

export function DeleteClient({ onDelete }: { onDelete: () =&gt; Promise&lt;void&gt; }) {
  return (
    &lt;HapticButton tone="danger" variant="soft" mode="hold" fullWidth leftIcon={&lt;Trash2 /&gt;} onConfirm={onDelete}&gt;
      Hold to delete
    &lt;/HapticButton&gt;
  );
}
</code></pre>
<p>On Android the tick uses <code>navigator.vibrate</code>. Safari on iPhone has no Vibration API, so the haptics helper falls back to clicking a hidden native switch (<code>&lt;input type="checkbox" switch&gt;</code>). That played the system tick up to iOS 26.4; from iOS 26.5 WebKit ticks only for the user's own tap on the switch, so on a current iPhone the fill carries the feedback on its own. Desktops and mouse clicks stay silent. The <a href="https://wingo-ui.com/blog/web-vibration-api-haptic-feedback">Web Vibration API guide</a> covers what still ticks on an iPhone and how to let people turn haptics off.</p>
<h2>Does shadcn/ui handle mobile?</h2>
<p>Partly. As of October 2026 the shadcn mobile story is one component and one pattern. The <a href="https://ui.shadcn.com/docs/components/drawer">Drawer docs</a> come in a Base UI version, which now uses Base UI's drawer instead of Vaul, and a Radix version, which is still <a href="https://ui.shadcn.com/docs/components/radix/drawer">built on Vaul</a>. Both include a Responsive section that renders a <code>Dialog</code> on desktop and a <code>Drawer</code> on mobile; the example decides with <code>useMediaQuery("(min-width: 768px)")</code> and shares one form between both branches. The <a href="https://ui.shadcn.com/docs/components/popover">Popover docs</a> describe no mobile behavior, and Select and Dropdown Menu stay floating panels at every width unless you write a wrapper for each.</p>
<p>That suits a library meant as a minimal, unopinionated starting point. shadcn/ui is free and MIT licensed, its GitHub repository shows about 125k stars (as of October 2026), and its docs let you choose between Base UI, Radix and React Aria primitives. If your app is used mostly on desktops, or you want to write and own every responsive wrapper yourself, it is the better choice. Our <a href="https://wingo-ui.com/blog/best-react-component-library-for-mobile">comparison of the best React component libraries for mobile</a> checks shadcn/ui and eight others at 390px.</p>
<p>If most of your users arrive on phones, the wrappers add up: every overlay needs the swap, every control needs the coarse-pointer size, every fixed bar needs the safe area, and every one of them has to avoid the hydration flash. Wingo UI makes those decisions inside each component, so the defaults are already mobile-first and you opt out per instance.</p>
<h2>Which components does a React mobile web app need?</h2>
<p>A React mobile web app usually needs overlays that become sheets, a tab bar for the primary navigation, a bottom action bar on product and checkout pages, lists with swipe actions, and a table that becomes cards. Here is the set in Wingo UI (registry 1.7.0, 326 items as of October 2026), with what each one does on a phone:</p>
<table>
<thead>
<tr>
<th>Component</th>
<th>Job on a phone</th>
<th>Access</th>
</tr>
</thead>
<tbody><tr>
<td><a href="https://wingo-ui.com/components/drawer">Drawer</a></td>
<td>The bottom sheet: drag to dismiss, snap points, nesting</td>
<td>Free</td>
</tr>
<tr>
<td>Popover, Select, Dropdown Menu, Dialog</td>
<td>Open as bottom sheets below 768px</td>
<td>Free</td>
</tr>
<tr>
<td><a href="https://wingo-ui.com/components/segmented-control">Segmented Control</a></td>
<td>A few options on a thumb you can drag</td>
<td>Free</td>
</tr>
<tr>
<td><a href="https://wingo-ui.com/components/bottom-tab-bar">Bottom Tab Bar</a></td>
<td>3 to 5 primary destinations at the thumb</td>
<td>Pro</td>
</tr>
<tr>
<td><a href="https://wingo-ui.com/components/sticky-cta-bar">Sticky CTA Bar</a></td>
<td>Price and the main action, above the tab bar</td>
<td>Pro</td>
</tr>
<tr>
<td><a href="https://wingo-ui.com/components/pull-to-refresh">Pull To Refresh</a></td>
<td>Reload a list with a pull, or a button</td>
<td>Pro</td>
</tr>
<tr>
<td><a href="https://wingo-ui.com/components/list">List</a></td>
<td>Grouped rows with swipe actions and drag to reorder</td>
<td>Pro</td>
</tr>
<tr>
<td><a href="https://wingo-ui.com/components/data-table">Data Table</a></td>
<td>Rows as cards, long press to select</td>
<td>Pro</td>
</tr>
<tr>
<td><a href="https://wingo-ui.com/components/haptic-button">Haptic Button</a></td>
<td>Press feedback, haptics and hold to confirm</td>
<td>Pro</td>
</tr>
<tr>
<td><a href="https://wingo-ui.com/components/large-title-header">Large Title Header</a></td>
<td>An iOS large title that collapses into a bar</td>
<td>Pro</td>
</tr>
<tr>
<td><a href="https://wingo-ui.com/components/action-sheet">Action Sheet</a></td>
<td>A short list of actions about one thing</td>
<td>Pro</td>
</tr>
<tr>
<td><a href="https://wingo-ui.com/components/picker-wheel">Picker Wheel</a></td>
<td>iOS wheels for times and dates</td>
<td>Pro</td>
</tr>
</tbody></table>
<p>The whole <a href="https://wingo-ui.com/components/category/mobile">Mobile category</a> has 15 components, all built to these rules, with reduced motion support and designed light and dark themes.</p>
<h2>How do you test mobile-first React components?</h2>
<p>Test at 390 by 844 and 360 by 780 with touch enabled, then on a real iPhone for the parts emulators get wrong. Chrome's device mode switches the pointer to coarse, which is enough to see <code>pointer-coarse</code> styles and the sheets. It does not reproduce the iOS keyboard, the focus zoom, the home indicator or the toolbar resizing, so those need a phone.</p>
<p>For automated checks, Playwright can emulate a touch phone per test file:</p>
<pre><code class="language-ts">import { expect, test } from "@playwright/test";

test.use({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true });

test("filters open as a bottom sheet on a phone", async ({ page }) =&gt; {
  await page.goto("/listings");
  await page.getByRole("button", { name: "Filters" }).tap();
  await expect(page.getByRole("dialog", { name: "Filters" })).toBeVisible();
});
</code></pre>
<p>Run the same pass in light and dark and with reduced motion turned on. A sheet handle or a hairline drawn at a low opacity can be clear in one theme and vanish in the other, and under reduced motion every overlay still has to open and close, only with a shorter slide or a fade.</p>
<h2>Which guides go deeper on each rule?</h2>
<p>Each of these posts in <a href="https://wingo-ui.com/blog/category/mobile-ui">Mobile UI in React</a> takes one rule or one component further:</p>
<ul>
<li><a href="https://wingo-ui.com/blog/shadcn-responsive-dialog-on-mobile">Shadcn responsive dialog</a>: a centered card on desktop and a bottom sheet on phones, with the content written once.</li>
<li><a href="https://wingo-ui.com/blog/react-bottom-sheet-for-the-web">React bottom sheet for the web</a>: snap points, drag to dismiss, nested sheets and the keyboard on vaul.</li>
<li><a href="https://wingo-ui.com/blog/minimum-touch-target-size-react-tailwind">Minimum touch target size</a>: the WCAG, iOS and Android numbers, and 44px on touch in Tailwind without a bloated desktop.</li>
<li><a href="https://wingo-ui.com/blog/tailwind-safe-area-insets">Tailwind safe area insets</a>: bottom bars, FABs and toasts clear of the notch and the home indicator.</li>
<li><a href="https://wingo-ui.com/blog/100vh-mobile-dvh-tailwind">100vh on mobile</a>: when to use dvh, svh or lvh, and full-height shells that do not jump.</li>
<li><a href="https://wingo-ui.com/blog/react-bottom-navigation-bar">React bottom navigation bar</a>: a tab bar with badges, a center action and hide on scroll.</li>
<li><a href="https://wingo-ui.com/blog/react-pull-to-refresh">React pull to refresh</a>: a custom refresh in a PWA that does not fight the browser's own.</li>
<li><a href="https://wingo-ui.com/blog/web-vibration-api-haptic-feedback">Web Vibration API</a>: the haptic feedback a page can trigger on Android and iPhone.</li>
<li><a href="https://wingo-ui.com/blog/react-pwa-install-prompt">React PWA install prompt</a>: an install prompt for Chrome, Edge and iPhone, and when not to ask.</li>
<li><a href="https://wingo-ui.com/blog/responsive-table-mobile-cards">Responsive tables on mobile</a>: when rows become cards, which columns survive, and sorting and bulk actions without headers.</li>
<li><a href="https://wingo-ui.com/blog/react-segmented-control">React segmented control</a>: the iOS thumb you can drag, radio semantics, and when tabs or a toggle group fit better.</li>
<li><a href="https://wingo-ui.com/blog/react-wheel-picker">React wheel picker</a>: iOS wheels for times and dates on a native snapping scroller, with a typed field on desktop.</li>
</ul>
<h2>Where should you start?</h2>
<p>Start with the overlays, because they are the most visible mobile failure and the cheapest to fix. Install the free Popover with <code>npx wingo-ui@latest add popover</code> (it brings the Drawer, the Button and the media query hook along), swap it in for one panel that people open on phones, and try it on your own device. The tab bar, sticky CTA bar, pull to refresh, haptic button and data table are part of <a href="https://wingo-ui.com/pricing">Wingo UI Pro</a>.</p>
<h2>Components in this post</h2>
<ul>
<li><a href="https://wingo-ui.com/components/drawer">Drawer</a> (Free): The library's bottom sheet on vaul: drag to dismiss, snap points, nesting, and a side panel or dialog on desktop. Install: <code>npx wingo-ui@latest add drawer</code></li>
<li><a href="https://wingo-ui.com/components/popover">Popover</a> (Free): Rich floating content anchored to a trigger, like a quick filter, form or share panel, that becomes a bottom sheet on phones. Install: <code>npx wingo-ui@latest add popover</code></li>
<li><a href="https://wingo-ui.com/components/bottom-tab-bar">Bottom Tab Bar</a> (Pro): The phone's primary navigation: 3 to 5 tabs at the thumb with an animated pill, badges, a raised center action and hide on scroll. Install: <code>npx wingo-ui@latest add bottom-tab-bar</code></li>
<li><a href="https://wingo-ui.com/components/pull-to-refresh">Pull To Refresh</a> (Pro): Pull a list down past its top to reload it, with rubberband resistance, an arrow, iOS spokes or a Material ring, and a haptic tick. Install: <code>npx wingo-ui@latest add pull-to-refresh</code></li>
<li><a href="https://wingo-ui.com/components/sticky-cta-bar">Sticky CTA Bar</a> (Pro): The bottom action bar of product, listing and checkout pages on phones: the price on one side, the main action on the other. Install: <code>npx wingo-ui@latest add sticky-cta-bar</code></li>
<li><a href="https://wingo-ui.com/components/data-table">Data Table</a> (Pro): The admin table on TanStack Table v9: search, filters, sorting, bulk actions, pinning, resizing, virtualization and cards on phones. Install: <code>npx wingo-ui@latest add data-table</code></li>
<li><a href="https://wingo-ui.com/components/haptic-button">Haptic Button</a> (Pro): A button that answers like a native control: spring press, touch ripple, a haptic tick, a long-press action and a hold-to-confirm mode. Install: <code>npx wingo-ui@latest add haptic-button</code></li>
</ul>
<h2>FAQ</h2>
<h3>What is a mobile-first React component?</h3>
<p>A component designed for touch, a narrow screen and an on-screen keyboard first, then enhanced for a mouse and a wide window. On a phone it changes its behavior as well as its layout: it opens a bottom sheet instead of a popover, keeps 44px hit areas and has no hover-only controls.</p>
<h3>How big should buttons be in a mobile web app?</h3>
<p>WCAG 2.2 asks for 24 by 24 CSS pixels at level AA and 44 by 44 at level AAA. Apple's guidelines use 44 by 44 points as the default iOS control size and Android recommends 48 by 48 dp. A practical rule for the web is 44px on coarse pointers with 8px between targets.</p>
<h3>Should I use a useIsMobile hook or CSS media queries?</h3>
<p>Use CSS breakpoints for anything visible on the first paint, because the server cannot know the screen width and a hook returns a guess there. Use the hook for behavior that starts after an interaction, such as opening a bottom sheet instead of a popover.</p>
<h3>Does shadcn/ui have a bottom sheet for mobile?</h3>
<p>Yes, the Drawer. As of October 2026 its Base UI version is built on Base UI's drawer and its Radix version on Vaul, and the docs show a responsive example that renders a Dialog on desktop and a Drawer on mobile. Popover, Select and Dropdown Menu do not switch to a sheet on their own.</p>
<h3>Why do hover effects not work on phones?</h3>
<p>Touch screens have no hover, and in Tailwind CSS v4 the hover variant only applies when the primary input can hover, so a control that only appears on hover never shows up on a phone. Reveal it on focus and on coarse pointers as well, or replace the hover with a tap or a long press.</p>
<h3>Which Wingo UI mobile components are free?</h3>
<p>The Drawer, Sheet, Popover, Dialog, Select, Dropdown Menu, Segmented Control, Button and the useMediaQuery hook are free, and the Popover, Dialog, Select and Dropdown Menu open as bottom sheets below 768px by default. The 15 components of the Mobile category (tab bar, sticky CTA bar, pull to refresh, haptic button and more) and the Data Table are Pro.</p>
<hr />
<p><em>This article was originally published on the Wingo UI blog: <a href="https://wingo-ui.com/blog/mobile-first-react-components">Mobile-First React Components: 9 Rules With Code</a>. The original has the live, interactive demos and is kept up to date.</em></p>
]]></content:encoded></item></channel></rss>