<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Wingo UI</title>
    <description>The latest articles on DEV Community by Wingo UI (wingo-ui).</description>
    <link>https://dev.to/wingo-ui</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Forganization%2Fprofile_image%2F15415%2F3d958290-6c30-4c8b-9ae5-fbab7bb46f06.png</url>
      <title>DEV Community: Wingo UI</title>
      <link>https://dev.to/wingo-ui</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/wingo-ui"/>
    <language>en</language>
    <item>
      <title>React Bottom Sheet for the Web: Snap Points, Drag, Keyboard</title>
      <dc:creator>Serban Dan Rusu</dc:creator>
      <pubDate>Thu, 22 Oct 2026 15:00:00 +0000</pubDate>
      <link>https://dev.to/wingo-ui/react-bottom-sheet-for-the-web-snap-points-drag-keyboard-1cco</link>
      <guid>https://dev.to/wingo-ui/react-bottom-sheet-for-the-web-snap-points-drag-keyboard-1cco</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Search for a React bottom sheet and many of the top results are React Native packages such as &lt;a class="mentioned-user" href="https://dev.to/gorhom"&gt;@gorhom&lt;/a&gt;/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, &lt;code&gt;position: fixed&lt;/code&gt;, 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 &lt;a href="https://wingo-ui.com/components/drawer" rel="noopener noreferrer"&gt;Drawer&lt;/a&gt;, which is built on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which React bottom sheet library works on the web?
&lt;/h2&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Library&lt;/th&gt;
&lt;th&gt;Built on&lt;/th&gt;
&lt;th&gt;Snap points&lt;/th&gt;
&lt;th&gt;Nested sheets&lt;/th&gt;
&lt;th&gt;Latest release&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/emilkowalski/vaul" rel="noopener noreferrer"&gt;vaul&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Radix Dialog&lt;/td&gt;
&lt;td&gt;Fractions or px strings&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Drawer.NestedRoot&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1.1.2, Dec 14, 2024&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://base-ui.com/react/components/drawer" rel="noopener noreferrer"&gt;Base UI Drawer&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Base UI Dialog&lt;/td&gt;
&lt;td&gt;Fractions, px or rem&lt;/td&gt;
&lt;td&gt;Yes, styled with CSS variables&lt;/td&gt;
&lt;td&gt;@base-ui/react 1.8.0, Sep 4, 2026&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/temzasse/react-modal-sheet" rel="noopener noreferrer"&gt;react-modal-sheet&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Motion&lt;/td&gt;
&lt;td&gt;Fractions or px, including 0 (closed) and 1&lt;/td&gt;
&lt;td&gt;Not documented&lt;/td&gt;
&lt;td&gt;5.6.0, Mar 27, 2026&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/stipsan/react-spring-bottom-sheet" rel="noopener noreferrer"&gt;react-spring-bottom-sheet&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;react-spring, react-use-gesture&lt;/td&gt;
&lt;td&gt;A function of the content's min and max height&lt;/td&gt;
&lt;td&gt;Not documented&lt;/td&gt;
&lt;td&gt;3.4.1, Jun 1, 2022&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;If your project already uses Base UI, the default for new shadcn/ui projects since &lt;a href="https://ui.shadcn.com/docs/changelog/2026-07-base-ui-default" rel="noopener noreferrer"&gt;July 2026&lt;/a&gt;, use its Drawer: it is maintained and covers snap points, nesting and the keyboard (&lt;code&gt;Drawer.VirtualKeyboardProvider&lt;/code&gt;). On Radix, vaul is still the most complete option, and the rest of this guide is about the gaps you cover around it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do vaul drawer snap points work?
&lt;/h2&gt;

&lt;p&gt;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 (&lt;code&gt;0.45&lt;/code&gt; is 45%), a string is pixels (&lt;code&gt;"320px"&lt;/code&gt;). The sheet renders at full height and is translated down to the active point. Three details trip people up in vaul 1.1.2:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Fractions from 0 to 1.&lt;/strong&gt; The type comment says "Array of numbers from 0 to 100", but the code multiplies each number by the window height, so &lt;code&gt;50&lt;/code&gt; means fifty screens. Write &lt;code&gt;0.5&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pixels only.&lt;/strong&gt; A string goes through &lt;code&gt;parseInt&lt;/code&gt;, so &lt;code&gt;"30rem"&lt;/code&gt; becomes 30 pixels.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hoist the array.&lt;/strong&gt; Vaul re-runs its snap effect whenever &lt;code&gt;snapPoints&lt;/code&gt; changes identity, so an inline literal re-snaps the sheet on every parent render.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;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. &lt;code&gt;fadeFromIndex&lt;/code&gt; picks the point where the overlay starts to dim; it defaults to the last one, so a half-open sheet leaves the page bright.&lt;/p&gt;

&lt;p&gt;Make the React bottom sheet modal when it holds a task. Pass &lt;code&gt;modal={false}&lt;/code&gt; when the page behind must stay usable, like a results list over a map:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;use client&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Drawer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;DrawerSnapPoint&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/components/ui/drawer&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// fractions of the screen height, least visible first; one array for the app's lifetime&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;SNAP_POINTS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DrawerSnapPoint&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;0.45&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Apartment&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;rent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;MapResults&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;apartments&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;apartments&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Apartment&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;snap&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setSnap&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;DrawerSnapPoint&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;SNAP_POINTS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Drawer&lt;/span&gt;
      &lt;span class="na"&gt;defaultOpen&lt;/span&gt;
      &lt;span class="na"&gt;modal&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;dismissible&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;snapPoints&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;SNAP_POINTS&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;activeSnapPoint&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;snap&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;onActiveSnapPointChange&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;setSnap&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Apartments in Chicago"&lt;/span&gt;
      &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;apartments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; results in the map area`&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;ul&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"flex flex-col gap-3"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;apartments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;li&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"flex justify-between gap-3 text-[15px]"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
            &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"truncate"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
            &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"font-semibold tabular-nums"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rent&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;li&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;ul&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Drawer&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With snap points, &lt;code&gt;dismissible={false}&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  How does drag to dismiss decide when to close?
&lt;/h2&gt;

&lt;p&gt;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 (&lt;code&gt;closeThreshold&lt;/code&gt;, default 0.25) or flick it faster than 0.4 pixels per millisecond; anything less eases back over 500ms on &lt;code&gt;cubic-bezier(0.32, 0.72, 0, 1)&lt;/code&gt;. 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:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;from inside a scrollable element that is not at its top, so a long list scrolls first;&lt;/li&gt;
&lt;li&gt;for a moment after the content scrolled (&lt;code&gt;scrollLockTimeout&lt;/code&gt;: 100ms in the code, although the type comment says 500ms);&lt;/li&gt;
&lt;li&gt;while text is selected, from a native &lt;code&gt;&amp;lt;select&amp;gt;&lt;/code&gt;, or from anything inside &lt;code&gt;[data-vaul-no-drag]&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;in the first 500ms after opening.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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 &lt;code&gt;handleOnly&lt;/code&gt;, 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 &lt;code&gt;showClose&lt;/code&gt; (panels and dialogs always have one).&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Live demo: Drawer.&lt;/strong&gt; 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). &lt;a href="https://wingo-ui.com/blog/react-bottom-sheet-for-the-web" rel="noopener noreferrer"&gt;Try it live on wingo-ui.com&lt;/a&gt; or open the &lt;a href="https://wingo-ui.com/components/drawer" rel="noopener noreferrer"&gt;Drawer docs&lt;/a&gt;. Install: &lt;code&gt;npx wingo-ui@latest add drawer&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  How do you nest bottom sheets?
&lt;/h2&gt;

&lt;p&gt;Open the second sheet from inside the first and push the first one back, like iOS. In vaul that is &lt;code&gt;Drawer.NestedRoot&lt;/code&gt;, rendered inside the parent's &lt;code&gt;Drawer.Content&lt;/code&gt;; 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 &lt;code&gt;onOpenChange&lt;/code&gt;, so a child opened by setting &lt;code&gt;open&lt;/code&gt; from state leaves the parent flat.&lt;/p&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;use client&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ReactNode&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Button&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/components/ui/button&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Drawer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;DrawerBody&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;DrawerClose&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;DrawerContent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;DrawerFooter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;DrawerHeader&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;DrawerRoot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;DrawerTrigger&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/components/ui/drawer&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;DeliveryAddress&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;addresses&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;newAddressForm&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;addresses&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ReactNode&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;newAddressForm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ReactNode&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;DrawerRoot&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;DrawerTrigger&lt;/span&gt; &lt;span class="na"&gt;asChild&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt; &lt;span class="na"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"soft"&lt;/span&gt; &lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"sm"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Change&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;DrawerTrigger&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;DrawerContent&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;DrawerHeader&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Delivery address"&lt;/span&gt; &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Arrives in 1-2 business days"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;DrawerBody&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;addresses&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
          &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* inside the first sheet's content, so it nests and pushes the first one back */&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Drawer&lt;/span&gt;
            &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"New address"&lt;/span&gt;
            &lt;span class="na"&gt;trigger&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt; &lt;span class="na"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"ghost"&lt;/span&gt; &lt;span class="na"&gt;fullWidth&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Add a new address&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
            &lt;span class="na"&gt;footer&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;
              &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;DrawerClose&lt;/span&gt; &lt;span class="na"&gt;asChild&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
                &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt; &lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"lg"&lt;/span&gt; &lt;span class="na"&gt;fullWidth&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Save address&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
              &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;DrawerClose&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
            &lt;span class="si"&gt;}&lt;/span&gt;
          &lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
            &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;newAddressForm&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Drawer&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;DrawerBody&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;DrawerFooter&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;DrawerClose&lt;/span&gt; &lt;span class="na"&gt;asChild&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
            &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt; &lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"lg"&lt;/span&gt; &lt;span class="na"&gt;fullWidth&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Deliver here&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;DrawerClose&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;DrawerFooter&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;DrawerContent&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;DrawerRoot&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Stop at two levels. On a 390px screen a third sheet is a page, so navigate to one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happens when the keyboard opens inside a bottom sheet?
&lt;/h2&gt;

&lt;p&gt;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 (&lt;a href="https://developer.chrome.com/blog/viewport-resize-behavior" rel="noopener noreferrer"&gt;Chrome's announcement&lt;/a&gt;), so an element at &lt;code&gt;position: fixed; bottom: 0&lt;/code&gt; stays behind it. Vaul's &lt;code&gt;repositionInputs&lt;/code&gt;, on by default, listens to &lt;code&gt;visualViewport&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;Two rules make that work in practice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Do not focus a field on open.&lt;/strong&gt; 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 &lt;code&gt;onOpenAutoFocus&lt;/code&gt;, call &lt;code&gt;event.preventDefault()&lt;/code&gt; and focus the field yourself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use 16px text in inputs&lt;/strong&gt; (&lt;code&gt;text-base md:text-sm&lt;/code&gt;). iOS Safari zooms into a focused field with smaller text.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Turn &lt;code&gt;repositionInputs&lt;/code&gt; off when the sheet handles the keyboard itself, for example a chat composer that follows the keyboard with the Pro &lt;a href="https://wingo-ui.com/components/use-keyboard-inset" rel="noopener noreferrer"&gt;useKeyboardInset&lt;/a&gt; hook.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you keep a bottom sheet clear of the home indicator?
&lt;/h2&gt;

&lt;p&gt;Let the page reach the screen edges with &lt;code&gt;viewport-fit=cover&lt;/code&gt; (in Next.js, &lt;code&gt;viewportFit: "cover"&lt;/code&gt; in the root layout's &lt;code&gt;viewport&lt;/code&gt; export), then pad the part of the sheet that touches the bottom edge with &lt;code&gt;max(env(safe-area-inset-bottom), 16px)&lt;/code&gt;. The &lt;code&gt;max()&lt;/code&gt; 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 &lt;code&gt;92dvh&lt;/code&gt; so a strip of the page stays visible to tap. On a plain vaul drawer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;use client&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ReactNode&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Drawer&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;vaul&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;SheetProps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;trigger&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ReactNode&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;children&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ReactNode&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;actions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ReactNode&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;BottomSheet&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;trigger&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;actions&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="nx"&gt;SheetProps&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Drawer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Root&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Drawer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Trigger&lt;/span&gt; &lt;span class="na"&gt;asChild&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;trigger&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Drawer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Trigger&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Drawer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Portal&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Drawer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Overlay&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"fixed inset-0 z-40 bg-black/40"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Drawer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Content&lt;/span&gt;
          &lt;span class="na"&gt;aria-describedby&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
          &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"fixed inset-x-0 bottom-0 z-50 flex max-h-[92dvh] flex-col rounded-t-[28px] bg-popover outline-none"&lt;/span&gt;
        &lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Drawer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Handle&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"mt-2.5"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Drawer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Title&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"px-5 pt-3 pb-2 text-[17px] font-semibold"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Drawer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Title&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"min-h-0 flex-1 overflow-y-auto overscroll-contain px-5"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* the part that touches the bottom edge pads the home indicator */&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"flex flex-col gap-2 px-5 pt-3 pb-[max(env(safe-area-inset-bottom),16px)]"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;actions&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Drawer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Content&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Drawer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Portal&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Drawer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Root&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Wingo UI Drawer pads whichever part comes last. Its &lt;code&gt;variant="floating"&lt;/code&gt; instead sits 8px above the safe area with every corner rounded, the look the &lt;a href="https://wingo-ui.com/components/action-sheet" rel="noopener noreferrer"&gt;Action Sheet&lt;/a&gt; uses for its iOS variant. The same insets for tab bars, FABs and toasts are in our &lt;a href="https://wingo-ui.com/blog/tailwind-safe-area-insets" rel="noopener noreferrer"&gt;Tailwind safe area insets tutorial&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which bottom sheet should you use for each job?
&lt;/h2&gt;

&lt;p&gt;There is no single bottom sheet UI React apps should use for everything. Pick by the job:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Use it for&lt;/th&gt;
&lt;th&gt;Built on&lt;/th&gt;
&lt;th&gt;Access&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://wingo-ui.com/components/drawer" rel="noopener noreferrer"&gt;Drawer&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Forms, filters, pickers, results over a map; a side panel or a dialog from 768px (&lt;code&gt;desktop&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;vaul&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://wingo-ui.com/components/action-sheet" rel="noopener noreferrer"&gt;Action Sheet&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;A short list of actions about one thing, with a separate Cancel and &lt;code&gt;await show()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Drawer&lt;/td&gt;
&lt;td&gt;Pro&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://wingo-ui.com/components/family-drawer" rel="noopener noreferrer"&gt;Family Drawer&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;A floating sheet that morphs between views, such as a menu and a confirmation&lt;/td&gt;
&lt;td&gt;vaul&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://wingo-ui.com/components/sheet" rel="noopener noreferrer"&gt;Sheet&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;A side panel on desktop that becomes a bottom sheet on phones (&lt;code&gt;mobileSide="bottom"&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Radix Dialog and motion&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The Action Sheet also works imperatively: mount an &lt;code&gt;ActionSheetProvider&lt;/code&gt; once, take &lt;code&gt;const actionSheet = useActionSheet()&lt;/code&gt; in a component, and in a click handler &lt;code&gt;await actionSheet.show({ actions })&lt;/code&gt; resolves with the chosen action's id, or &lt;code&gt;null&lt;/code&gt; on Cancel or a dismiss. An action whose &lt;code&gt;onSelect&lt;/code&gt; 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.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Live demo: Action Sheet.&lt;/strong&gt; 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). &lt;a href="https://wingo-ui.com/blog/react-bottom-sheet-for-the-web" rel="noopener noreferrer"&gt;Try it live on wingo-ui.com&lt;/a&gt; or open the &lt;a href="https://wingo-ui.com/components/action-sheet" rel="noopener noreferrer"&gt;Action Sheet docs&lt;/a&gt;. Install: &lt;code&gt;npx wingo-ui@latest add action-sheet&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Live demo: Family Drawer.&lt;/strong&gt; 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). &lt;a href="https://wingo-ui.com/blog/react-bottom-sheet-for-the-web" rel="noopener noreferrer"&gt;Try it live on wingo-ui.com&lt;/a&gt; or open the &lt;a href="https://wingo-ui.com/components/family-drawer" rel="noopener noreferrer"&gt;Family Drawer docs&lt;/a&gt;. Install: &lt;code&gt;npx wingo-ui@latest add family-drawer&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What should you do next?
&lt;/h2&gt;

&lt;p&gt;Install the free Drawer with &lt;code&gt;npx wingo-ui@latest add drawer&lt;/code&gt;, 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 &lt;a href="https://wingo-ui.com/blog/shadcn-responsive-dialog-on-mobile" rel="noopener noreferrer"&gt;shadcn responsive dialog tutorial&lt;/a&gt;; for touch targets, safe areas and the rest of the phone rules, the &lt;a href="https://wingo-ui.com/blog/mobile-first-react-components" rel="noopener noreferrer"&gt;mobile-first React components guide&lt;/a&gt; and the &lt;a href="https://wingo-ui.com/blog/category/mobile-ui" rel="noopener noreferrer"&gt;Mobile UI in React&lt;/a&gt; category. The Action Sheet is part of &lt;a href="https://wingo-ui.com/pricing" rel="noopener noreferrer"&gt;Wingo UI Pro&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Components in this post
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://wingo-ui.com/components/drawer" rel="noopener noreferrer"&gt;Drawer&lt;/a&gt; (Free): The library's bottom sheet on vaul: drag to dismiss, snap points, nesting, and a side panel or dialog on desktop. Install: &lt;code&gt;npx wingo-ui@latest add drawer&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://wingo-ui.com/components/family-drawer" rel="noopener noreferrer"&gt;Family Drawer&lt;/a&gt; (Free): A floating bottom drawer that morphs smoothly between stacked views, inspired by the Family app. Install: &lt;code&gt;npx wingo-ui@latest add family-drawer&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://wingo-ui.com/components/action-sheet" rel="noopener noreferrer"&gt;Action Sheet&lt;/a&gt; (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: &lt;code&gt;npx wingo-ui@latest add action-sheet&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://wingo-ui.com/components/sheet" rel="noopener noreferrer"&gt;Sheet&lt;/a&gt; (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: &lt;code&gt;npx wingo-ui@latest add sheet&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Can I use &lt;a class="mentioned-user" href="https://dev.to/gorhom"&gt;@gorhom&lt;/a&gt;/bottom-sheet in a React web app?
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is vaul still maintained?
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why do my vaul snap points not work with values like 50?
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I stop a vaul drawer from dragging when I swipe a carousel inside it?
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is the Wingo UI Drawer free?
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article was originally published on the Wingo UI blog: &lt;a href="https://wingo-ui.com/blog/react-bottom-sheet-for-the-web" rel="noopener noreferrer"&gt;React Bottom Sheet for the Web: Snap Points, Drag, Keyboard&lt;/a&gt;. The original has the live, interactive demos and is kept up to date.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>react</category>
      <category>mobile</category>
      <category>ux</category>
      <category>frontend</category>
    </item>
    <item>
      <title>Claude Code shadcn Setup: MCP Server, Skill and CLAUDE.md</title>
      <dc:creator>Serban Dan Rusu</dc:creator>
      <pubDate>Thu, 22 Oct 2026 07:00:00 +0000</pubDate>
      <link>https://dev.to/wingo-ui/claude-code-shadcn-setup-mcp-server-skill-and-claudemd-525o</link>
      <guid>https://dev.to/wingo-ui/claude-code-shadcn-setup-mcp-server-skill-and-claudemd-525o</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;components/ui&lt;/code&gt;, and it has never seen the registries you added to &lt;code&gt;components.json&lt;/code&gt; 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 &lt;code&gt;components.json&lt;/code&gt;, 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.&lt;/p&gt;

&lt;p&gt;Everything below was run in October 2026 against shadcn 4.21.4, the &lt;code&gt;skills&lt;/code&gt; 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 &lt;a href="https://wingo-ui.com/blog/component-library-mcp-guide" rel="noopener noreferrer"&gt;component library MCP guide&lt;/a&gt; first.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does a Claude Code shadcn setup need?
&lt;/h2&gt;

&lt;p&gt;A server that searches registries, instructions that say when to use it, a list of registries and a CLI that installs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Piece&lt;/th&gt;
&lt;th&gt;Job&lt;/th&gt;
&lt;th&gt;How you add it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;shadcn MCP server&lt;/td&gt;
&lt;td&gt;Searches every registry in &lt;code&gt;components.json&lt;/code&gt;, returns add commands&lt;/td&gt;
&lt;td&gt;&lt;code&gt;npx shadcn@latest mcp init --client claude&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;shadcn skill&lt;/td&gt;
&lt;td&gt;Project config and shadcn/ui composition rules&lt;/td&gt;
&lt;td&gt;&lt;code&gt;npx skills add shadcn/ui&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;components.json&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The registries to search, with auth headers&lt;/td&gt;
&lt;td&gt;Edit by hand&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;CLAUDE.md&lt;/code&gt; block&lt;/td&gt;
&lt;td&gt;Which registry owns which folder and which CLI installs it&lt;/td&gt;
&lt;td&gt;Six lines by hand&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The CLI&lt;/td&gt;
&lt;td&gt;Writes files, installs npm packages&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;npx shadcn@latest add&lt;/code&gt; or a library's own&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;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 &lt;code&gt;.mcp.json&lt;/code&gt; through &lt;code&gt;npx wingo-ui@latest agents install claude&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I add the shadcn MCP server to Claude Code?
&lt;/h2&gt;

&lt;p&gt;Run one command in the project root, restart Claude Code and check &lt;code&gt;/mcp&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx shadcn@latest mcp init &lt;span class="nt"&gt;--client&lt;/span&gt; claude
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It writes this entry to &lt;code&gt;.mcp.json&lt;/code&gt;, then installs &lt;code&gt;shadcn&lt;/code&gt; as a dev dependency:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"shadcn"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"shadcn@latest"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"mcp"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;.mcp.json&lt;/code&gt; 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, &lt;code&gt;claude mcp add shadcn -- npx shadcn@latest mcp&lt;/code&gt; adds it in local scope, the default, instead.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which tools does the shadcn MCP server give Claude?
&lt;/h3&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;get_project_registries&lt;/code&gt;: the registries in &lt;code&gt;components.json&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;list_items_in_registries&lt;/code&gt; and &lt;code&gt;search_items_in_registries&lt;/code&gt;: browse or fuzzy-search, with paging.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;view_items_in_registries&lt;/code&gt;: name, description, type, file count and npm dependencies.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;get_item_examples_from_registries&lt;/code&gt;: demo code, found by names like &lt;code&gt;dialog-demo&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;get_add_command_for_items&lt;/code&gt;: the &lt;code&gt;npx shadcn@latest add&lt;/code&gt; command for items.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;get_audit_checklist&lt;/code&gt;: a checklist for after generating code.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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 &lt;code&gt;view_items_in_registries&lt;/code&gt; returned neither props nor source for &lt;code&gt;@shadcn/button&lt;/code&gt; or our dialog; examples exist only where a registry publishes &lt;code&gt;-demo&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;One bug in 4.21.4: search results print each add command as &lt;code&gt;[object Promise]&lt;/code&gt;. &lt;code&gt;get_add_command_for_items&lt;/code&gt; returns the real one, &lt;code&gt;npx shadcn@latest add @shadcn/button @wingo-ui/dialog&lt;/code&gt; in our test.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I add a second registry, private or Pro?
&lt;/h2&gt;

&lt;p&gt;Add it under &lt;code&gt;registries&lt;/code&gt; in &lt;code&gt;components.json&lt;/code&gt;. A public registry needs only its URL template:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"registries"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"@wingo-ui"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://wingo-ui.com/r/{name}.json"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A shadcn private registry takes headers, with the secret in an environment variable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"registries"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"@wingo-ui"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://wingo-ui.com/r/{name}.json"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"headers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Authorization"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bearer ${WINGO_UI_API_KEY}"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The shadcn CLI reads that variable from the environment or from &lt;code&gt;.env.local&lt;/code&gt;; we confirmed both. When the key is set, &lt;code&gt;agents install&lt;/code&gt; writes our own MCP entry in &lt;code&gt;.mcp.json&lt;/code&gt; with the same &lt;code&gt;${WINGO_UI_API_KEY}&lt;/code&gt;, and Claude Code expands it from the environment it started in. Export the key in your shell profile and both servers see it.&lt;/p&gt;

&lt;p&gt;Use the header form only when the variable is set. Without it, every call that touches &lt;code&gt;@wingo-ui&lt;/code&gt; fails with &lt;code&gt;MISSING_ENV_VARS&lt;/code&gt;: a search across &lt;code&gt;@shadcn&lt;/code&gt; and &lt;code&gt;@wingo-ui&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;Then one search covers both registries. This is the real answer to &lt;code&gt;search_items_in_registries&lt;/code&gt; for "login" with a limit of 4, the broken add-command lines removed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;173 matches for one word is fuzzy matching at work (&lt;code&gt;@shadcn&lt;/code&gt; alone returned 7), so ask for a small limit.&lt;/p&gt;

&lt;h3&gt;
  
  
  What happens when two registries both write components/ui/button.tsx?
&lt;/h3&gt;

&lt;p&gt;Neither CLI replaces the other's file silently, and either answer breaks something. shadcn/ui and many shadcn-style libraries install into &lt;code&gt;components/ui&lt;/code&gt; with the same file names and different APIs. When we added our &lt;a href="https://wingo-ui.com/components/dialog" rel="noopener noreferrer"&gt;Dialog&lt;/a&gt; to a project that already had the shadcn button, our CLI reported a conflict on &lt;code&gt;components/ui/button.tsx&lt;/code&gt;, 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 &lt;code&gt;radius="full"&lt;/code&gt;, a prop shadcn's Button does not have. Replacing it breaks every &lt;code&gt;variant="default"&lt;/code&gt;, &lt;code&gt;"secondary"&lt;/code&gt; or &lt;code&gt;"destructive"&lt;/code&gt; in your app, because our &lt;a href="https://wingo-ui.com/components/button" rel="noopener noreferrer"&gt;Button&lt;/a&gt; variants are &lt;code&gt;solid&lt;/code&gt;, &lt;code&gt;soft&lt;/code&gt;, &lt;code&gt;outline&lt;/code&gt;, &lt;code&gt;ghost&lt;/code&gt; and &lt;code&gt;link&lt;/code&gt; (destructive is &lt;code&gt;tone="danger"&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Give the second library its own folder. Our CLI reads its paths from &lt;code&gt;wingo-ui.json&lt;/code&gt;, so after &lt;code&gt;npx wingo-ui@latest init&lt;/code&gt;, change the &lt;code&gt;ui&lt;/code&gt; alias and keep the rest:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"aliases"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"ui"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"@/components/wingo"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"blocks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"@/components/blocks"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"@/hooks"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"lib"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"@/lib"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"utils"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"@/lib/utils"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;npx wingo-ui@latest add dialog&lt;/code&gt; then wrote &lt;code&gt;button.tsx&lt;/code&gt;, &lt;code&gt;drawer.tsx&lt;/code&gt; and &lt;code&gt;dialog.tsx&lt;/code&gt; into &lt;code&gt;components/wingo&lt;/code&gt; beside the untouched shadcn button and rewrote their imports to &lt;code&gt;@/components/wingo/...&lt;/code&gt;. &lt;code&gt;init&lt;/code&gt; had already merged our design tokens into &lt;code&gt;app/globals.css&lt;/code&gt;, and &lt;code&gt;add&lt;/code&gt; appended the drawer's transitions. That is the second reason to install our items with our CLI: the shadcn-format JSON at &lt;code&gt;/r/&amp;lt;slug&amp;gt;.json&lt;/code&gt; carries no tokens, so in a project without them, &lt;code&gt;z-modal&lt;/code&gt; and &lt;code&gt;shadow-modal&lt;/code&gt; on a shadcn-installed dialog resolve to nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should I install the Claude Code shadcn skill too?
&lt;/h2&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx skills add shadcn/ui &lt;span class="nt"&gt;--agent&lt;/span&gt; claude-code
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;skills&lt;/code&gt; CLI wrote two skills into &lt;code&gt;.claude/skills&lt;/code&gt;, &lt;code&gt;shadcn&lt;/code&gt; and &lt;code&gt;migrate-radix-to-base&lt;/code&gt;, and recorded both in &lt;code&gt;skills-lock.json&lt;/code&gt;. Claude Code keeps only a skill's description in context until the skill is used, so the 19.5 KB &lt;code&gt;SKILL.md&lt;/code&gt; costs little until a UI task starts. Its description matches any project with a &lt;code&gt;components.json&lt;/code&gt;, so expect it on most UI work.&lt;/p&gt;

&lt;p&gt;When it loads, it runs &lt;code&gt;npx shadcn@latest info --json&lt;/code&gt; and puts your framework, Tailwind version, aliases, base library and installed components in front of Claude. Its &lt;code&gt;allowed-tools&lt;/code&gt; line also lets Claude run any &lt;code&gt;npx shadcn@latest&lt;/code&gt; command without asking for the rest of that turn, &lt;code&gt;add --overwrite&lt;/code&gt; included. Three of its rules matter before you prompt:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Name the registry.&lt;/strong&gt; Ask for "a login block" without one and the skill tells Claude to ask you which registry to use.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docs first.&lt;/strong&gt; Claude runs &lt;code&gt;npx shadcn@latest docs &amp;lt;component&amp;gt;&lt;/code&gt; before using one. That command only knows shadcn/ui: &lt;code&gt;docs dialog&lt;/code&gt; returned links to the shadcn/ui and Radix Dialog docs, and &lt;code&gt;docs @wingo-ui/dialog&lt;/code&gt; answered that the component was not found in the shadcn registry.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;shadcn/ui composition.&lt;/strong&gt; Forms use &lt;code&gt;FieldGroup&lt;/code&gt; and &lt;code&gt;Field&lt;/code&gt;, icons in buttons take &lt;code&gt;data-icon&lt;/code&gt;, and since shadcn's Button has no loading prop, Claude composes a &lt;code&gt;Spinner&lt;/code&gt; and &lt;code&gt;disabled&lt;/code&gt; by hand.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All three are right for shadcn/ui files, and the last two are wrong for other registries. Our Button has a &lt;code&gt;loading&lt;/code&gt; prop that shows a spinner in place of the label, keeps the width, sets &lt;code&gt;aria-busy&lt;/code&gt; 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.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Live demo: Button.&lt;/strong&gt; 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). &lt;a href="https://wingo-ui.com/blog/claude-code-shadcn-mcp-setup" rel="noopener noreferrer"&gt;Try it live on wingo-ui.com&lt;/a&gt; or open the &lt;a href="https://wingo-ui.com/components/button" rel="noopener noreferrer"&gt;Button docs&lt;/a&gt;. Install: &lt;code&gt;npx wingo-ui@latest add button&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What should CLAUDE.md say?
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;CLAUDE.md&lt;/code&gt; at the start of every session, so this block is always in context:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;## UI components&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; Two registries. &lt;span class="sb"&gt;`components/ui`&lt;/span&gt; is shadcn/ui (&lt;span class="sb"&gt;`@shadcn`&lt;/span&gt; in components.json). &lt;span class="sb"&gt;`components/wingo`&lt;/span&gt; is Wingo UI (versions in wingo-ui.json); import it from &lt;span class="sb"&gt;`@/components/wingo/&amp;lt;name&amp;gt;`&lt;/span&gt;.
&lt;span class="p"&gt;-&lt;/span&gt; Before writing UI, search: the &lt;span class="sb"&gt;`shadcn`&lt;/span&gt; MCP tools for @shadcn and other registries, the &lt;span class="sb"&gt;`wingo-ui`&lt;/span&gt; MCP tools for Wingo UI. Never hand-write a component a registry has.
&lt;span class="p"&gt;-&lt;/span&gt; Install shadcn items with &lt;span class="sb"&gt;`npx shadcn@latest add @shadcn/&amp;lt;name&amp;gt;`&lt;/span&gt; and Wingo UI items with &lt;span class="sb"&gt;`npx wingo-ui@latest add &amp;lt;slug&amp;gt;`&lt;/span&gt;. Never install a Wingo UI item with the shadcn CLI.
&lt;span class="p"&gt;-&lt;/span&gt; Read the API of the file you import. The shadcn skill's rules and &lt;span class="sb"&gt;`shadcn docs`&lt;/span&gt; apply to &lt;span class="sb"&gt;`components/ui`&lt;/span&gt; only; for Wingo UI items call &lt;span class="sb"&gt;`get_component`&lt;/span&gt; and configure them through their props.
&lt;span class="p"&gt;-&lt;/span&gt; Semantic tokens only, no hex colors. Check 390px and 1440px wide, in light and dark.
&lt;span class="p"&gt;-&lt;/span&gt; Never pass &lt;span class="sb"&gt;`--overwrite`&lt;/span&gt; without asking me. Before any update, show me the diff or the release notes.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first line answers what no tool can: which folder belongs to whom. It matters twice in this layout, because our own skill, which &lt;code&gt;agents install&lt;/code&gt; writes, assumes the default folder and tells Claude that imports use &lt;code&gt;@/components/ui/&amp;lt;name&amp;gt;&lt;/code&gt;. The fourth line keeps shadcn/ui patterns out of files with a different API. The last repeats the skill's own &lt;code&gt;--overwrite&lt;/code&gt; 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 &lt;a href="https://wingo-ui.com/blog/agents-md-vs-claude-md-react" rel="noopener noreferrer"&gt;AGENTS.md vs CLAUDE.md comparison&lt;/a&gt; explains which file each agent reads.&lt;/p&gt;

&lt;p&gt;Allow the read-only tools in &lt;code&gt;.claude/settings.json&lt;/code&gt; and leave installs on ask:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"permissions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"allow"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"mcp__shadcn"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"mcp__wingo-ui"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(npx shadcn@latest search *)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(npx shadcn@latest view *)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(npx wingo-ui@latest outdated)"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Which prompts get Claude to install the right component?
&lt;/h2&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Find before you build.&lt;/strong&gt; "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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build on one component's real API.&lt;/strong&gt; "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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Start screens from a block.&lt;/strong&gt; "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 &lt;a href="https://wingo-ui.com/components/login-page" rel="noopener noreferrer"&gt;Login Page&lt;/a&gt; ships simulated handlers so its demo works, and each one you do not pass stays simulated: without &lt;code&gt;onVerifyCode&lt;/code&gt;, the code views accept &lt;code&gt;123456&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ask for updates as a report.&lt;/strong&gt; "Check my Wingo UI components and my shadcn/ui button for upstream changes. Show me the notes and the diff, and change nothing."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;End with a check.&lt;/strong&gt; "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 &lt;code&gt;variant="destructive"&lt;/code&gt; on our Button.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For prompt 2, this is the file a good run ends with, on the real APIs of the &lt;a href="https://wingo-ui.com/components/dialog" rel="noopener noreferrer"&gt;Dialog&lt;/a&gt;, the Button and the &lt;a href="https://wingo-ui.com/components/input" rel="noopener noreferrer"&gt;Input&lt;/a&gt;. We type-checked it against release 1.7.0:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/settings/profile-dialog.tsx&lt;/span&gt;
&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;use client&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Pencil&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;lucide-react&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// next to shadcn/ui these imports read @/components/wingo/...&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Button&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/components/ui/button&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Dialog&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;DialogClose&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/components/ui/dialog&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Input&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/components/ui/input&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;ProfileDialogProps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;initialName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;onSave&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ProfileDialog&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;initialName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onSave&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="nx"&gt;ProfileDialogProps&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;open&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setOpen&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setName&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;initialName&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;saving&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setSaving&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setError&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;changeOpen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// start from the saved name every time it opens&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;setName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;initialName&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nf"&gt;setError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;setOpen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;setSaving&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;setError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;onSave&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
      &lt;span class="nf"&gt;setOpen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;setError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Your name was not saved. Try again.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;setSaving&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Dialog&lt;/span&gt;
      &lt;span class="na"&gt;open&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;open&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;onOpenChange&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;changeOpen&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;trigger&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt; &lt;span class="na"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"soft"&lt;/span&gt; &lt;span class="na"&gt;leftIcon&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Pencil&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
          Edit profile
        &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Edit profile"&lt;/span&gt;
      &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Your name appears on invoices and in comments."&lt;/span&gt;
      &lt;span class="na"&gt;icon&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Pencil&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;footer&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&amp;gt;&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;DialogClose&lt;/span&gt; &lt;span class="na"&gt;asChild&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
            &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt; &lt;span class="na"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"soft"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Cancel&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;DialogClose&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt; &lt;span class="na"&gt;tone&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"brand"&lt;/span&gt; &lt;span class="na"&gt;loading&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;saving&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;loadingText&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Saving"&lt;/span&gt; &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;save&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
            Save
          &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;/&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Input&lt;/span&gt;
        &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Full name"&lt;/span&gt;
        &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"name"&lt;/span&gt;
        &lt;span class="na"&gt;autoComplete&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"name"&lt;/span&gt;
        &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;onValueChange&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;setName&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Dialog&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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 &lt;a href="https://wingo-ui.com/blog/shadcn-responsive-dialog-on-mobile" rel="noopener noreferrer"&gt;shadcn responsive dialog&lt;/a&gt; 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.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Live demo: Dialog.&lt;/strong&gt; 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). &lt;a href="https://wingo-ui.com/blog/claude-code-shadcn-mcp-setup" rel="noopener noreferrer"&gt;Try it live on wingo-ui.com&lt;/a&gt; or open the &lt;a href="https://wingo-ui.com/components/dialog" rel="noopener noreferrer"&gt;Dialog docs&lt;/a&gt;. Install: &lt;code&gt;npx wingo-ui@latest add dialog&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  How do I keep the installed components up to date?
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;For shadcn/ui files, &lt;code&gt;npx shadcn@latest add button --diff&lt;/code&gt; shows your file against the registry, and &lt;code&gt;--overwrite&lt;/code&gt; replaces it, edits included. The skill tells Claude to merge by hand when a file has local changes. Our guide to &lt;a href="https://wingo-ui.com/blog/update-shadcn-components-without-losing-edits" rel="noopener noreferrer"&gt;updating shadcn components without losing edits&lt;/a&gt; has a git script that runs a real 3-way merge instead.&lt;/p&gt;

&lt;p&gt;For Wingo UI files, the CLI recorded each item's version and kept a pristine copy at install, so it merges for you:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx wingo-ui@latest outdated        &lt;span class="c"&gt;# installed items vs the latest release&lt;/span&gt;
npx wingo-ui@latest diff dialog     &lt;span class="c"&gt;# your file vs upstream&lt;/span&gt;
npx wingo-ui@latest update dialog   &lt;span class="c"&gt;# 3-way merge that keeps your edits&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With our MCP server connected, &lt;code&gt;check_updates&lt;/code&gt; returns the same list with release notes, so prompt 4 works inside the session.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where should I start?
&lt;/h2&gt;

&lt;p&gt;On shadcn/ui alone: add the MCP server, install the skill, write the three rules lines, and name &lt;code&gt;@shadcn&lt;/code&gt; in every prompt. If you want a second registry built for phones, the Dialog, Button and Input from this post are free; &lt;code&gt;npx wingo-ui@latest agents install claude&lt;/code&gt; adds our MCP server and skill next to shadcn's, and the &lt;a href="https://wingo-ui.com/docs/mcp" rel="noopener noreferrer"&gt;MCP server docs&lt;/a&gt; cover the setup. The Login Page block is part of Wingo UI Pro, on the &lt;a href="https://wingo-ui.com/pricing" rel="noopener noreferrer"&gt;pricing page&lt;/a&gt;. More guides like this one live under &lt;a href="https://wingo-ui.com/blog/category/ai-agents" rel="noopener noreferrer"&gt;Building UI with AI agents&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Components in this post
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://wingo-ui.com/components/button" rel="noopener noreferrer"&gt;Button&lt;/a&gt; (Free): The button every screen starts with: five variants, six tones, three sizes plus icon sizes, and a loading state that never jumps. Install: &lt;code&gt;npx wingo-ui@latest add button&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://wingo-ui.com/components/dialog" rel="noopener noreferrer"&gt;Dialog&lt;/a&gt; (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: &lt;code&gt;npx wingo-ui@latest add dialog&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://wingo-ui.com/components/login-page" rel="noopener noreferrer"&gt;Login Page&lt;/a&gt; (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: &lt;code&gt;npx wingo-ui@latest add login-page&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://wingo-ui.com/components/input" rel="noopener noreferrer"&gt;Input&lt;/a&gt; (Free): The single-line text field: icons and addons inside the box, a clear button, a loading spinner, a counter and floating labels. Install: &lt;code&gt;npx wingo-ui@latest add input&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  How do I install the shadcn MCP server in Claude Code?
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the difference between the shadcn skill and the shadcn MCP server?
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does the shadcn MCP server install components by itself?
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does a shadcn MCP search fail with MISSING_ENV_VARS?
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I use shadcn/ui and another registry in the same project?
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article was originally published on the Wingo UI blog: &lt;a href="https://wingo-ui.com/blog/claude-code-shadcn-mcp-setup" rel="noopener noreferrer"&gt;Claude Code shadcn Setup: MCP Server, Skill and CLAUDE.md&lt;/a&gt;. The original has the live, interactive demos and is kept up to date.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>react</category>
      <category>shadcn</category>
      <category>mcp</category>
    </item>
    <item>
      <title>Tailwind v4 Design Tokens: Colors, Dark Mode and Contrast</title>
      <dc:creator>Serban Dan Rusu</dc:creator>
      <pubDate>Wed, 21 Oct 2026 15:00:00 +0000</pubDate>
      <link>https://dev.to/wingo-ui/tailwind-v4-design-tokens-colors-dark-mode-and-contrast-310c</link>
      <guid>https://dev.to/wingo-ui/tailwind-v4-design-tokens-colors-dark-mode-and-contrast-310c</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;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 &lt;a class="mentioned-user" href="https://dev.to/theme"&gt;@theme&lt;/a&gt; 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.&lt;/p&gt;

&lt;p&gt;Tailwind CSS v4 moved configuration out of &lt;code&gt;tailwind.config.js&lt;/code&gt; and into CSS, and many guides stop at the first step: put a few brand colors in &lt;code&gt;@theme&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What are design tokens in Tailwind v4?
&lt;/h2&gt;

&lt;p&gt;Design tokens in Tailwind v4 are CSS custom properties. Tailwind v4 theme variables, the ones you declare inside an &lt;code&gt;@theme&lt;/code&gt; block, do two jobs at once: they are CSS variables you can read with &lt;code&gt;var()&lt;/code&gt;, and their namespace decides which utilities Tailwind generates. &lt;code&gt;--color-surface&lt;/code&gt; creates &lt;code&gt;bg-surface&lt;/code&gt;, &lt;code&gt;text-surface&lt;/code&gt; and &lt;code&gt;border-surface&lt;/code&gt;; &lt;code&gt;--shadow-raised&lt;/code&gt; creates &lt;code&gt;shadow-raised&lt;/code&gt;. Tailwind CSS v4.0 shipped this CSS-first configuration on January 22, 2025 (&lt;a href="https://tailwindcss.com/blog/tailwindcss-v4" rel="noopener noreferrer"&gt;release post&lt;/a&gt;), and the &lt;a href="https://tailwindcss.com/docs/theme" rel="noopener noreferrer"&gt;theme variables docs&lt;/a&gt; list the namespaces. We run Tailwind 4.3.3 as of October 2026.&lt;/p&gt;

&lt;p&gt;The namespaces a component library uses most:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Namespace&lt;/th&gt;
&lt;th&gt;Utilities it creates&lt;/th&gt;
&lt;th&gt;What we keep there&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--color-*&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;bg-*&lt;/code&gt;, &lt;code&gt;text-*&lt;/code&gt;, &lt;code&gt;border-*&lt;/code&gt;, &lt;code&gt;ring-*&lt;/code&gt;, &lt;code&gt;fill-*&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;surfaces, text levels, tones, borders, the focus ring&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--shadow-*&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;shadow-*&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;three elevation levels&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--radius-*&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;rounded-*&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;a legacy scale that new code avoids (see the radius section)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--z-index-*&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;z-*&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;seven stacking layers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;--ease-*&lt;/code&gt;, &lt;code&gt;--transition-duration-*&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ease-*&lt;/code&gt;, &lt;code&gt;duration-*&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;motion curves and durations&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--font-*&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;font-*&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;font families&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--spacing&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;p-*&lt;/code&gt;, &lt;code&gt;m-*&lt;/code&gt;, &lt;code&gt;gap-*&lt;/code&gt;, &lt;code&gt;w-*&lt;/code&gt;, &lt;code&gt;h-*&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;the 4px grid (&lt;code&gt;0.25rem&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;--z-index-*&lt;/code&gt; and &lt;code&gt;--transition-duration-*&lt;/code&gt; are not in the docs table as of October 2026, but Tailwind 4.3 compiles them: our CSS has &lt;code&gt;.z-modal { z-index: var(--z-modal) }&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How should you layer Tailwind v4 design tokens?
&lt;/h2&gt;

&lt;p&gt;Use three layers. Raw values that change between themes go in &lt;code&gt;:root&lt;/code&gt; and &lt;code&gt;.dark&lt;/code&gt;. A mapping in &lt;code&gt;@theme inline&lt;/code&gt; turns them into utilities. Components then paint through a few runtime variables that a prop sets, so a &lt;code&gt;tone&lt;/code&gt; or a custom &lt;code&gt;color&lt;/code&gt; never needs its own class.&lt;/p&gt;

&lt;p&gt;Here is a trimmed but working excerpt of our &lt;code&gt;app/globals.css&lt;/code&gt;, with the real values:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="k"&gt;@import&lt;/span&gt; &lt;span class="s1"&gt;"tailwindcss"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;@custom-variant&lt;/span&gt; &lt;span class="n"&gt;dark&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;is&lt;/span&gt;&lt;span class="p"&gt;(.&lt;/span&gt;&lt;span class="n"&gt;dark&lt;/span&gt; &lt;span class="err"&gt;*&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="nd"&gt;:root&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;--background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;oklch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="py"&gt;--foreground&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;oklch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0.3211&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="py"&gt;--surface&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#ffffff&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--popover&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;oklch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="py"&gt;--muted-foreground&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;oklch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0.52&lt;/span&gt; &lt;span class="m"&gt;0.0234&lt;/span&gt; &lt;span class="m"&gt;264.3637&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="py"&gt;--success&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#15803d&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--success-foreground&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#ffffff&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--success-soft&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#e7f6ec&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--success-soft-foreground&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#166534&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--elevation-raised&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt; &lt;span class="nb"&gt;rgb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="m"&gt;0.07&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt; &lt;span class="m"&gt;2px&lt;/span&gt; &lt;span class="nb"&gt;rgb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="m"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;2px&lt;/span&gt; &lt;span class="m"&gt;8px&lt;/span&gt; &lt;span class="m"&gt;-2px&lt;/span&gt; &lt;span class="nb"&gt;rgb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="m"&gt;0.06&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.dark&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;--background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;oklch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="py"&gt;--foreground&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;oklch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0.9219&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="py"&gt;--surface&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#1a1a1a&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--popover&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#262626&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--muted-foreground&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;oklch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0.7155&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="py"&gt;--success-soft&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#0d2315&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--success-soft-foreground&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#4ade80&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--elevation-raised&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;inset&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="nb"&gt;rgb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;255&lt;/span&gt; &lt;span class="m"&gt;255&lt;/span&gt; &lt;span class="m"&gt;255&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="m"&gt;0.06&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt; &lt;span class="nb"&gt;rgb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;255&lt;/span&gt; &lt;span class="m"&gt;255&lt;/span&gt; &lt;span class="m"&gt;255&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="m"&gt;0.08&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt; &lt;span class="m"&gt;2px&lt;/span&gt; &lt;span class="nb"&gt;rgb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="m"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;@theme&lt;/span&gt; &lt;span class="nb"&gt;inline&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;--color-background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--background&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="py"&gt;--color-foreground&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--foreground&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="py"&gt;--color-surface&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--surface&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="py"&gt;--color-popover&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--popover&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="py"&gt;--color-muted-foreground&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--muted-foreground&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="py"&gt;--color-success&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--success&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="py"&gt;--color-success-foreground&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--success-foreground&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="py"&gt;--color-success-soft&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--success-soft&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="py"&gt;--color-success-soft-foreground&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--success-soft-foreground&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="py"&gt;--shadow-raised&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--elevation-raised&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The success fill and its white text are the same in both themes (5.02:1), so this excerpt declares them only in &lt;code&gt;:root&lt;/code&gt; and &lt;code&gt;.dark&lt;/code&gt; inherits them. Everything that changes is redeclared under &lt;code&gt;.dark&lt;/code&gt;, and a component uses the same classes everywhere:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;InvoiceRow&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"flex items-center justify-between rounded-2xl bg-surface p-4 text-foreground shadow-raised"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"text-[15px] font-medium"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Invoice 1042&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"text-sm text-muted-foreground"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Emma Carter, New York&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"rounded-full bg-success-soft px-2 py-0.5 text-xs font-medium text-success-soft-foreground"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Paid&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The raw names follow shadcn/ui's convention on purpose: &lt;code&gt;--background&lt;/code&gt;, &lt;code&gt;--card&lt;/code&gt;, &lt;code&gt;--popover&lt;/code&gt; and &lt;code&gt;--muted-foreground&lt;/code&gt; are shadcn names, and the ones shadcn does not have (&lt;code&gt;--surface&lt;/code&gt;, &lt;code&gt;--canvas&lt;/code&gt;, the &lt;code&gt;--success-*&lt;/code&gt; family) use the same &lt;code&gt;name&lt;/code&gt; and &lt;code&gt;name-foreground&lt;/code&gt; pattern. A project that already has shadcn tokens can take ours without renaming anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  When do you need Tailwind v4 &lt;a class="mentioned-user" href="https://dev.to/theme"&gt;@theme&lt;/a&gt; inline?
&lt;/h2&gt;

&lt;p&gt;Use &lt;code&gt;@theme inline&lt;/code&gt; whenever a theme variable points at another variable, which is every token that changes with the theme. The &lt;a href="https://tailwindcss.com/docs/theme" rel="noopener noreferrer"&gt;Tailwind docs&lt;/a&gt; say it directly: with &lt;code&gt;inline&lt;/code&gt;, "the utility class will use the theme variable value instead of referencing the actual theme variable". Here is what that means in compiled CSS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="c"&gt;/* @theme inline { --color-surface: var(--surface); } */&lt;/span&gt;
&lt;span class="nc"&gt;.bg-surface&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;background-color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--surface&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;/* @theme { --color-surface: var(--surface); } */&lt;/span&gt;
&lt;span class="nd"&gt;:root&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nd"&gt;:host&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;--color-surface&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--surface&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nc"&gt;.bg-surface&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;background-color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--color-surface&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The difference shows up below the root. A custom property inherits its computed value, and &lt;code&gt;var()&lt;/code&gt; is substituted where the property is declared. In the second version &lt;code&gt;--color-surface&lt;/code&gt; is resolved once, on &lt;code&gt;:root&lt;/code&gt;, and every element inherits that result. A &lt;code&gt;.dark&lt;/code&gt; class on &lt;code&gt;&amp;lt;html&amp;gt;&lt;/code&gt; still works, because &lt;code&gt;&amp;lt;html&amp;gt;&lt;/code&gt; is the root. A dark section inside a light page (&lt;code&gt;&amp;lt;section className="dark"&amp;gt;&lt;/code&gt;), a dark preview pane or a &lt;code&gt;data-theme&lt;/code&gt; attribute on a container does not: the section inherits the light value that &lt;code&gt;:root&lt;/code&gt; already resolved. With &lt;code&gt;inline&lt;/code&gt;, &lt;code&gt;bg-surface&lt;/code&gt; reads &lt;code&gt;var(--surface)&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;One catch remains with &lt;code&gt;inline&lt;/code&gt;: if you write &lt;code&gt;var(--color-surface)&lt;/code&gt; yourself, in an arbitrary value like &lt;code&gt;bg-[var(--color-surface)]&lt;/code&gt; or in plain CSS, Tailwind 4.3 emits &lt;code&gt;--color-surface: var(--surface)&lt;/code&gt; on &lt;code&gt;:root&lt;/code&gt; to satisfy it, and that reference is resolved on the root again. Inside components, read the raw variable instead: &lt;code&gt;bg-(--surface)&lt;/code&gt; or &lt;code&gt;var(--surface)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;@theme&lt;/code&gt; anyway; and Tailwind only emits the theme variables your code uses, unless you write &lt;code&gt;@theme static&lt;/code&gt;. The raw variables in &lt;code&gt;:root&lt;/code&gt; always ship, which helps when a chart library reads a token with &lt;code&gt;getComputedStyle&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which semantic color tokens does a component library need?
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;bg-gray-100&lt;/code&gt; or &lt;code&gt;text-blue-600&lt;/code&gt;, so a theme can change every value without touching a component.&lt;/p&gt;

&lt;h3&gt;
  
  
  Surfaces, back to front
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Token&lt;/th&gt;
&lt;th&gt;Light&lt;/th&gt;
&lt;th&gt;Dark&lt;/th&gt;
&lt;th&gt;Use&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;background&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;#FFFFFF&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;#000000&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the page&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;canvas&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;#F6F7F9&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;#0E0E10&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the page of an app (dashboards, admin), under white panels&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;card&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;#EBEBEB&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;#121212&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;flat gray tiles with no shadow&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;surface&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;#FFFFFF&lt;/code&gt; with &lt;code&gt;shadow-raised&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;#1A1A1A&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;raised panels: list groups, stat cards, forms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;popover&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;#FFFFFF&lt;/code&gt; with &lt;code&gt;shadow-floating&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;#262626&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;everything that floats: menus, selects, dialogs, sheets, toasts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;primary&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;#000000&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;#FAFAFA&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the neutral solid fill and tooltips&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In dark mode the surfaces get lighter as they come forward: &lt;code&gt;#000&lt;/code&gt;, &lt;code&gt;#121212&lt;/code&gt;, &lt;code&gt;#1A1A1A&lt;/code&gt;, &lt;code&gt;#262626&lt;/code&gt;. In light mode most of them are white, so the shadow does the separating. &lt;code&gt;canvas&lt;/code&gt; exists because a dashboard on a white page has nothing to set white cards against; a cool gray page makes every &lt;code&gt;bg-surface&lt;/code&gt; panel read as a card.&lt;/p&gt;

&lt;h3&gt;
  
  
  Text levels
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Level&lt;/th&gt;
&lt;th&gt;Class&lt;/th&gt;
&lt;th&gt;Use&lt;/th&gt;
&lt;th&gt;Lowest ratio in our token pairs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Primary&lt;/td&gt;
&lt;td&gt;&lt;code&gt;text-foreground&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;titles, values, labels&lt;/td&gt;
&lt;td&gt;10.60:1 (light card)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Secondary&lt;/td&gt;
&lt;td&gt;&lt;code&gt;text-muted-foreground&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;descriptions, helper text, timestamps&lt;/td&gt;
&lt;td&gt;4.62:1 (light card)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Disabled&lt;/td&gt;
&lt;td&gt;&lt;code&gt;text-foreground/45&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;disabled labels only&lt;/td&gt;
&lt;td&gt;none required&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tone text&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;text-success-soft-foreground&lt;/code&gt; and its siblings&lt;/td&gt;
&lt;td&gt;errors, deltas, links&lt;/td&gt;
&lt;td&gt;5.28:1 (brand on the light card)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;text-muted-foreground&lt;/code&gt; is the floor for anything people must read. The disabled level has no contrast target because &lt;a href="https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html" rel="noopener noreferrer"&gt;WCAG 2.2 success criterion 1.4.3&lt;/a&gt; exempts text in inactive controls, which is why it is never used for anything else.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tints instead of gray fills
&lt;/h3&gt;

&lt;p&gt;Hover states, field fills, chips, tracks and skeletons use translucent foreground: &lt;code&gt;bg-foreground/5&lt;/code&gt;, &lt;code&gt;/7&lt;/code&gt;, &lt;code&gt;/10&lt;/code&gt; and &lt;code&gt;/15&lt;/code&gt;, plus &lt;code&gt;border-foreground/10&lt;/code&gt; for hairlines. Tailwind 4.3 compiles the opacity modifier to &lt;code&gt;color-mix()&lt;/code&gt;, with the plain color as a fallback:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.bg-foreground&lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="err"&gt;5&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;background-color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--foreground&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="err"&gt;@supports&lt;/span&gt; &lt;span class="err"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;color-mix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;in&lt;/span&gt; &lt;span class="n"&gt;lab&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;red&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;red&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="err"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;background-color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;color-mix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;in&lt;/span&gt; &lt;span class="n"&gt;oklab&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--foreground&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="m"&gt;5%&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;transparent&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="err"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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 &lt;code&gt;--muted&lt;/code&gt; is &lt;code&gt;#171717&lt;/code&gt;: lighter than the black page and darker than the &lt;code&gt;#1A1A1A&lt;/code&gt; surface, so the same field would look raised on one and sunken on the other. The full-strength fallback only shows in browsers without &lt;code&gt;color-mix()&lt;/code&gt;, which are all below Tailwind's &lt;a href="https://tailwindcss.com/docs/compatibility" rel="noopener noreferrer"&gt;browser baseline&lt;/a&gt; of Chrome 111, Safari 16.4 and Firefox 128.&lt;/p&gt;

&lt;p&gt;shadcn/ui's &lt;code&gt;secondary&lt;/code&gt; and &lt;code&gt;accent&lt;/code&gt; stay in our file so ported components keep working, but our &lt;code&gt;--accent&lt;/code&gt; is an orange tint, and new components never use either.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do status colors work without a class per color?
&lt;/h2&gt;

&lt;p&gt;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 &lt;a href="https://wingo-ui.com/components/tones" rel="noopener noreferrer"&gt;Tones utility&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The four tokens per tone, for brand, success, warning, destructive and info:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nt"&gt;--success&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="err"&gt;#15803&lt;/span&gt;&lt;span class="nt"&gt;d&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;                 &lt;span class="c"&gt;/* bg-success: buttons, solid badges, progress */&lt;/span&gt;
&lt;span class="nt"&gt;--success-foreground&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;#ffffff&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;      &lt;span class="c"&gt;/* text on the fill */&lt;/span&gt;
&lt;span class="nt"&gt;--success-soft&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;#e7f6ec&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;            &lt;span class="c"&gt;/* bg-success-soft: soft badges, alerts, selected rows */&lt;/span&gt;
&lt;span class="nt"&gt;--success-soft-foreground&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="err"&gt;#166534&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="c"&gt;/* colored text on the tint and on any neutral surface */&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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. &lt;code&gt;text-destructive&lt;/code&gt; (&lt;code&gt;#DC2626&lt;/code&gt;) on our dark popover is 3.13:1; &lt;code&gt;text-destructive-soft-foreground&lt;/code&gt; (&lt;code&gt;#FF8A80&lt;/code&gt;) on the same surface is 6.63:1. The warning fill is amber (&lt;code&gt;#F5A524&lt;/code&gt;) at 2.04:1 against white, so it carries dark text (&lt;code&gt;#2B1700&lt;/code&gt;, 8.40:1) and is never used as text on a neutral surface.&lt;/p&gt;

&lt;h3&gt;
  
  
  Component variables
&lt;/h3&gt;

&lt;p&gt;A component with a &lt;code&gt;tone&lt;/code&gt; prop does not map tokens itself. &lt;code&gt;toneClasses[tone]&lt;/code&gt; sets five variables, and every variant paints with them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// lib/tones.ts (excerpt)&lt;/span&gt;
&lt;span class="nx"&gt;success&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;[--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)]&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The neutral tone maps &lt;code&gt;--tone&lt;/code&gt; to &lt;code&gt;--primary&lt;/code&gt; (black in light, near-white in dark) and builds its soft surface from &lt;code&gt;color-mix(in oklab, var(--foreground) 7%, transparent)&lt;/code&gt;, which is why a neutral soft button works on any surface. A badge built this way:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ComponentProps&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;colorVars&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;toneClasses&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Tone&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/lib/tones&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;cn&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/lib/utils&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;StatusBadgeProps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ComponentProps&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;span&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;tone&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;Tone&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;StatusBadge&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;tone&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;neutral&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;color&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;className&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="nx"&gt;StatusBadgeProps&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;
      &lt;span class="na"&gt;data-slot&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"status-badge"&lt;/span&gt;
      &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;cn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nx"&gt;toneClasses&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;tone&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;inline-flex rounded-full bg-(--tone-soft) px-2 py-0.5 text-xs font-medium text-(--tone-soft-foreground)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nx"&gt;className&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nf"&gt;colorVars&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;color&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;bg-(--tone-soft)&lt;/code&gt; is Tailwind v4's shorthand for &lt;code&gt;bg-[var(--tone-soft)]&lt;/code&gt;. The free &lt;a href="https://wingo-ui.com/components/badge" rel="noopener noreferrer"&gt;Badge&lt;/a&gt; is the full version of this pattern, with solid, soft and outline variants. &lt;code&gt;colorVars(color)&lt;/code&gt; builds the same five variables from any CSS color as an inline style. Its text-on-fill value uses relative color syntax, &lt;code&gt;oklch(from &amp;lt;color&amp;gt; clamp(0, (0.7 - l) * 1000, 1) 0 0)&lt;/code&gt;: white when the color's OKLCH lightness is below 0.7, black above. As of October 2026, &lt;a href="https://caniuse.com/css-relative-colors" rel="noopener noreferrer"&gt;caniuse&lt;/a&gt; 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 &lt;code&gt;className="[--tone-foreground:white]"&lt;/code&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Live demo: Tones.&lt;/strong&gt; 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). &lt;a href="https://wingo-ui.com/blog/tailwind-v4-design-tokens" rel="noopener noreferrer"&gt;Try it live on wingo-ui.com&lt;/a&gt; or open the &lt;a href="https://wingo-ui.com/components/tones" rel="noopener noreferrer"&gt;Tones docs&lt;/a&gt;. Install: &lt;code&gt;npx wingo-ui@latest add tones&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The free &lt;a href="https://wingo-ui.com/components/button" rel="noopener noreferrer"&gt;Button&lt;/a&gt; is the reference consumer. Its variant map contains no color at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;VARIANTS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;ButtonVariant&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;solid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bg-(--tone) text-(--tone-foreground) shadow-raised&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;soft&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bg-(--tone-soft) text-(--tone-soft-foreground)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;outline&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;border border-(--tone-border) text-(--tone-soft-foreground)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;ghost&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text-(--tone-soft-foreground)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;link&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rounded-[4px] text-(--tone-soft-foreground) underline decoration-current/35 decoration-1 underline-offset-4 transition-[color,text-decoration-color] hover:decoration-current&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Six tones times five variants, plus any custom color, from five lines. Hover and press are a layer of &lt;code&gt;currentColor&lt;/code&gt; at 8% and 12% opacity, so they also work for every tone without extra classes.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you design dark mode with tokens?
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;dark:&lt;/code&gt; prefix.&lt;/p&gt;

&lt;p&gt;The variant: our tokens, like shadcn/ui's, use &lt;code&gt;@custom-variant dark (&amp;amp;:is(.dark *));&lt;/code&gt;. The &lt;a href="https://tailwindcss.com/docs/dark-mode" rel="noopener noreferrer"&gt;Tailwind dark mode docs&lt;/a&gt; show &lt;code&gt;&amp;amp;:where(.dark, .dark *)&lt;/code&gt;, which also matches the element that carries the class and adds no specificity. With the class on &lt;code&gt;&amp;lt;html&amp;gt;&lt;/code&gt;, both behave the same for normal use. The class itself comes from &lt;a href="https://github.com/pacocoursey/next-themes" rel="noopener noreferrer"&gt;next-themes&lt;/a&gt;, which writes it before the first paint: wrap the app in its &lt;code&gt;ThemeProvider&lt;/code&gt; with &lt;code&gt;attribute="class"&lt;/code&gt; (the full provider file is in the props section below), and render &lt;code&gt;&amp;lt;html lang="en" suppressHydrationWarning&amp;gt;&lt;/code&gt; in the root layout, because next-themes changes the class before React hydrates:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/layout.tsx&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ReactNode&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Providers&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./providers&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./globals.css&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;RootLayout&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;children&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;children&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ReactNode&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;html&lt;/span&gt; &lt;span class="na"&gt;lang&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"en"&lt;/span&gt; &lt;span class="na"&gt;suppressHydrationWarning&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;body&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"bg-background text-foreground"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Providers&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Providers&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;body&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;html&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What a designed dark theme looks like in practice, from our token file:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Surfaces lighten as they come forward.&lt;/strong&gt; &lt;code&gt;#000&lt;/code&gt; page, &lt;code&gt;#121212&lt;/code&gt; card, &lt;code&gt;#1A1A1A&lt;/code&gt; surface, &lt;code&gt;#262626&lt;/code&gt; popover.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fills stay, soft surfaces flip.&lt;/strong&gt; The brand fill is &lt;code&gt;#D63F00&lt;/code&gt; in both themes, so white text keeps 4.59:1. Its soft surface goes from &lt;code&gt;#FFEFE6&lt;/code&gt; to &lt;code&gt;#2A1509&lt;/code&gt;, and its text from &lt;code&gt;#B03400&lt;/code&gt; to &lt;code&gt;#FF8A4C&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The neutral fill inverts.&lt;/strong&gt; &lt;code&gt;primary&lt;/code&gt; is black with white text in light mode and &lt;code&gt;#FAFAFA&lt;/code&gt; with &lt;code&gt;#171717&lt;/code&gt; in dark, so the neutral solid button keeps 21:1 in light and 17.16:1 in dark.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Elevation changes technique.&lt;/strong&gt; 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).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The focus ring shifts.&lt;/strong&gt; &lt;code&gt;--ring&lt;/code&gt; is &lt;code&gt;oklch(0.60 0.20 36.93)&lt;/code&gt; in light and &lt;code&gt;oklch(0.6617 0.2215 36.93)&lt;/code&gt; in dark, deeper on white so a 2px outline keeps 3:1 on white and on the gray card.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Some components need an exception.&lt;/strong&gt; Our soft &lt;a href="https://wingo-ui.com/components/card" rel="noopener noreferrer"&gt;Card&lt;/a&gt; uses &lt;code&gt;bg-card&lt;/code&gt; in light but &lt;code&gt;bg-foreground/7&lt;/code&gt; in dark, because the dark &lt;code&gt;--card&lt;/code&gt; is the same gray as a dark sidebar or stage and the card would vanish there. An inversion script never makes that call.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How do you check contrast for every token pair?
&lt;/h2&gt;

&lt;p&gt;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 (&lt;a href="https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html" rel="noopener noreferrer"&gt;1.4.3&lt;/a&gt;), and 3:1 for UI components and focus indicators against what is next to them (&lt;a href="https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html" rel="noopener noreferrer"&gt;1.4.11&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;brand-foreground&lt;/code&gt; on &lt;code&gt;brand&lt;/code&gt; at 4.59:1 and &lt;code&gt;muted-foreground&lt;/code&gt; on the light &lt;code&gt;card&lt;/code&gt; at 4.62:1. Here is a self-contained version you can drop into &lt;code&gt;scripts/&lt;/code&gt; and run with &lt;code&gt;npx tsx scripts/check-contrast.ts&lt;/code&gt;. It reads hex and &lt;code&gt;oklch()&lt;/code&gt; values (decimal or percent lightness), follows &lt;code&gt;var()&lt;/code&gt; references and exits with code 1 on a failure. It expects the token names used in this guide; if yours differ, edit &lt;code&gt;SURFACES&lt;/code&gt;, &lt;code&gt;TONES&lt;/code&gt; and &lt;code&gt;PAIRS&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// scripts/check-contrast.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;readFileSync&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;node:fs&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Rgb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;css&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;app/globals.css&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\/\*[\s\S]&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;?\*\/&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// every --name: value inside the blocks that start with this selector&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;vars&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;selector&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;map&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;block&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RegExp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`(?:^|&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;n)&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;selector&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;s*&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;{([^}]*)&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;}`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;g&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;css&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;matchAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;block&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;matchAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;--&lt;/span&gt;&lt;span class="se"&gt;[\w&lt;/span&gt;&lt;span class="sr"&gt;-&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)\s&lt;/span&gt;&lt;span class="sr"&gt;*:&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;([^&lt;/span&gt;&lt;span class="sr"&gt;;&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;;/g&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;map&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;map&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

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

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;map&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Rgb&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ref&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^var&lt;/span&gt;&lt;span class="se"&gt;\((&lt;/span&gt;&lt;span class="sr"&gt;--&lt;/span&gt;&lt;span class="se"&gt;[\w&lt;/span&gt;&lt;span class="sr"&gt;-&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)\)&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;map&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;map&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hex&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)].&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;ch&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;ch&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;ch&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;parseInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hex&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;Rgb&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^oklch&lt;/span&gt;&lt;span class="se"&gt;\(\s&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;([\d&lt;/span&gt;&lt;span class="sr"&gt;.&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)(&lt;/span&gt;&lt;span class="sr"&gt;%&lt;/span&gt;&lt;span class="se"&gt;?)\s&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;([\d&lt;/span&gt;&lt;span class="sr"&gt;.&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)\s&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;([\d&lt;/span&gt;&lt;span class="sr"&gt;.&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)\s&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;\)&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;oklchToRgb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`cannot parse "&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;channel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mf"&gt;0.04045&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;12.92&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.055&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;1.055&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="mf"&gt;2.4&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;luminance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt; &lt;span class="nx"&gt;Rgb&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.2126&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.7152&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.0722&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;contrast&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Rgb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;bg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Rgb&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;hi&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lo&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;luminance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fg&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;luminance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bg&lt;/span&gt;&lt;span class="p"&gt;)].&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hi&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lo&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;SURFACES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;background&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;card&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;popover&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;surface&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;canvas&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;TONES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;brand&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;success&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;warning&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;info&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;destructive&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PAIRS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;foreground&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;background&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;SURFACES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;muted-foreground&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
  &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;TONES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flatMap&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-foreground`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-soft-foreground`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-soft`&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;SURFACES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-soft-foreground`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
  &lt;span class="p"&gt;]),&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;light&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;vars&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;:root&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;themes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;light&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;dark&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;([...&lt;/span&gt;&lt;span class="nx"&gt;light&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nf"&gt;vars&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.dark&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)])&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;failures&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;theme&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;map&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;themes&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;fg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;bg&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;PAIRS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ratio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;contrast&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;map&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`--&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;fg&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;map&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;map&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`--&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;bg&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;map&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ratio&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;4.5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;failures&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`FAIL &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;theme&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;fg&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; on &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;bg&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; is &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ratio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toFixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;:1`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;PAIRS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; pairs checked, &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;failures&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; below 4.5:1`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exitCode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;failures&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should you write Tailwind v4 OKLCH values or hex?
&lt;/h2&gt;

&lt;p&gt;Use whichever format you can reason about; the format does not change contrast. Tailwind's own palette moved "from &lt;code&gt;rgb&lt;/code&gt; to &lt;code&gt;oklch&lt;/code&gt;, taking advantage of the wider gamut" in v4.0 (&lt;a href="https://tailwindcss.com/blog/tailwindcss-v4" rel="noopener noreferrer"&gt;release post&lt;/a&gt;), and its &lt;a href="https://tailwindcss.com/docs/colors" rel="noopener noreferrer"&gt;colors reference&lt;/a&gt; lists every Tailwind v4 OKLCH value, such as &lt;code&gt;--color-red-500: oklch(63.7% 0.237 25.331)&lt;/code&gt;. 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.&lt;/p&gt;

&lt;p&gt;In our file, neutrals are &lt;code&gt;oklch()&lt;/code&gt; with zero chroma (&lt;code&gt;oklch(0.3211 0 0)&lt;/code&gt; is the light foreground), and the tone values are hex, tuned by hand against the contrast check. The &lt;a href="https://wingo-ui.com/theme" rel="noopener noreferrer"&gt;theme generator&lt;/a&gt; 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 &lt;code&gt;oklchToRgb&lt;/code&gt; and &lt;code&gt;contrast&lt;/code&gt; from the contrast script into their own module, &lt;code&gt;scripts/color.ts&lt;/code&gt;, so importing them does not run the check, and the same search is a few lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;contrast&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;oklchToRgb&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./color&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Rgb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="c1"&gt;// keep hue and chroma, walk lightness until the color reads on every surface it can sit on&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;findReadable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;surfaces&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Rgb&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;4.6&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Rgb&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;step&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mf"&gt;0.005&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mf"&gt;0.005&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;l&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;step&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;l&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;l&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;l&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;step&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rgb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;oklchToRgb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;surfaces&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;bg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;contrast&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rgb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;bg&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;rgb&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;oklchToRgb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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 &lt;code&gt;oklchToRgb&lt;/code&gt; does.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do radius, elevation, z-index and motion become tokens?
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;z-[9999]&lt;/code&gt; somewhere.&lt;/p&gt;

&lt;h3&gt;
  
  
  Radius follows size
&lt;/h3&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Scale&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sm&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;md&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;lg&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;ButtonRadius&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;none&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sm&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;md&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;lg&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;full&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;RADII&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;ButtonRadius&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Scale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;none&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;sm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rounded-none&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;md&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rounded-none&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;lg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rounded-none&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;sm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;sm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rounded-[6px]&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;md&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rounded-[8px]&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;lg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rounded-[10px]&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;md&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;sm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rounded-[8px]&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;md&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rounded-[10px]&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;lg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rounded-[12px]&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;lg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;sm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rounded-[10px]&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;md&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rounded-[14px]&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;lg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rounded-[16px]&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;full&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;sm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rounded-full&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;md&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rounded-full&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;lg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rounded-full&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Surfaces use &lt;code&gt;rounded-2xl&lt;/code&gt; (16px) for cards, menus and popovers, &lt;code&gt;rounded-3xl&lt;/code&gt; (24px) for dialogs, and &lt;code&gt;rounded-t-[28px]&lt;/code&gt; for bottom sheets. Anything inside a padded surface takes the outer radius minus the padding: a &lt;code&gt;rounded-2xl p-1.5&lt;/code&gt; menu has &lt;code&gt;rounded-[10px]&lt;/code&gt; items. Sizes follow the same thinking: 32, 40 and 48px, with the 40px size growing to 44px on touch screens, the rule from our &lt;a href="https://wingo-ui.com/blog/mobile-first-react-components" rel="noopener noreferrer"&gt;mobile-first React components guide&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The trap is a global radius token. Our file still carries shadcn/ui's older scale for ported components: &lt;code&gt;--radius: 0.375rem&lt;/code&gt;, &lt;code&gt;--radius-lg: var(--radius)&lt;/code&gt; and &lt;code&gt;--radius-sm: calc(var(--radius) - 4px)&lt;/code&gt;. That remaps Tailwind's &lt;code&gt;rounded-sm&lt;/code&gt;, &lt;code&gt;rounded-md&lt;/code&gt;, &lt;code&gt;rounded-lg&lt;/code&gt; and &lt;code&gt;rounded-xl&lt;/code&gt; to 2, 4, 6 and 10px everywhere, including markup pasted from Tailwind's docs (the compiled &lt;code&gt;.rounded-lg&lt;/code&gt; is &lt;code&gt;border-radius: var(--radius)&lt;/code&gt;), so new components never use those four classes. As of October 2026, &lt;a href="https://ui.shadcn.com/docs/theming" rel="noopener noreferrer"&gt;shadcn/ui's theming docs&lt;/a&gt; derive the scale by multiplication instead (&lt;code&gt;--radius-sm: calc(var(--radius) * 0.6)&lt;/code&gt; up to &lt;code&gt;--radius-4xl&lt;/code&gt;), which has the same project-wide reach. If you theme radius globally, own every component that uses it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Three elevation levels
&lt;/h3&gt;

&lt;p&gt;Three levels cover a library: &lt;code&gt;shadow-raised&lt;/code&gt; for buttons, cards and thumbs, &lt;code&gt;shadow-floating&lt;/code&gt; for popovers, menus and toasts, &lt;code&gt;shadow-modal&lt;/code&gt; for dialogs and sheets. Each level carries its own 1px hairline, so an elevated surface needs no border:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nd"&gt;:root&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;--elevation-floating&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt; &lt;span class="nb"&gt;rgb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="m"&gt;0.06&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;4px&lt;/span&gt; &lt;span class="m"&gt;12px&lt;/span&gt; &lt;span class="m"&gt;-2px&lt;/span&gt; &lt;span class="nb"&gt;rgb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="m"&gt;0.08&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;16px&lt;/span&gt; &lt;span class="m"&gt;36px&lt;/span&gt; &lt;span class="m"&gt;-10px&lt;/span&gt; &lt;span class="nb"&gt;rgb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="m"&gt;0.16&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nc"&gt;.dark&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c"&gt;/* on a black page a drop shadow barely shows: a lit top edge and a hairline do the work */&lt;/span&gt;
  &lt;span class="py"&gt;--elevation-floating&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;inset&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="nb"&gt;rgb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;255&lt;/span&gt; &lt;span class="m"&gt;255&lt;/span&gt; &lt;span class="m"&gt;255&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="m"&gt;0.07&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt; &lt;span class="nb"&gt;rgb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;255&lt;/span&gt; &lt;span class="m"&gt;255&lt;/span&gt; &lt;span class="m"&gt;255&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="m"&gt;0.09&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;8px&lt;/span&gt; &lt;span class="m"&gt;24px&lt;/span&gt; &lt;span class="m"&gt;-4px&lt;/span&gt; &lt;span class="nb"&gt;rgb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="m"&gt;0.6&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;20px&lt;/span&gt; &lt;span class="m"&gt;48px&lt;/span&gt; &lt;span class="m"&gt;-12px&lt;/span&gt; &lt;span class="nb"&gt;rgb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="m"&gt;0.7&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;@theme&lt;/span&gt; &lt;span class="nb"&gt;inline&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;--shadow-floating&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--elevation-floating&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Live demo: Card.&lt;/strong&gt; 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). &lt;a href="https://wingo-ui.com/blog/tailwind-v4-design-tokens" rel="noopener noreferrer"&gt;Try it live on wingo-ui.com&lt;/a&gt; or open the &lt;a href="https://wingo-ui.com/components/card" rel="noopener noreferrer"&gt;Card docs&lt;/a&gt;. Install: &lt;code&gt;npx wingo-ui@latest add card&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Stacking layers
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Class&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;Use&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;z-dropdown&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1000&lt;/td&gt;
&lt;td&gt;in-flow panels that are not portaled&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;z-sticky&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1100&lt;/td&gt;
&lt;td&gt;sticky headers, tab bars, CTA bars&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;z-overlay&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1200&lt;/td&gt;
&lt;td&gt;the dimmed backdrop&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;z-modal&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1300&lt;/td&gt;
&lt;td&gt;dialogs, sheets, drawers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;z-popover&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1400&lt;/td&gt;
&lt;td&gt;portaled menus, selects, popovers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;z-toast&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1500&lt;/td&gt;
&lt;td&gt;toasts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;z-tooltip&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1600&lt;/td&gt;
&lt;td&gt;tooltips, always on top&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;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 &lt;code&gt;z-50&lt;/code&gt;; small values such as &lt;code&gt;z-10&lt;/code&gt; only order parts inside one component.&lt;/p&gt;

&lt;h3&gt;
  
  
  Motion
&lt;/h3&gt;

&lt;p&gt;CSS transitions use three durations (&lt;code&gt;duration-fast&lt;/code&gt; 150ms, &lt;code&gt;duration-normal&lt;/code&gt; 240ms, &lt;code&gt;duration-slow&lt;/code&gt; 400ms) and named curves (&lt;code&gt;ease-smooth&lt;/code&gt; is &lt;code&gt;cubic-bezier(0.22, 1, 0.36, 1)&lt;/code&gt;, &lt;code&gt;ease-sheet&lt;/code&gt; is &lt;code&gt;cubic-bezier(0.32, 0.72, 0, 1)&lt;/code&gt;). Our JavaScript springs are also sampled into CSS &lt;code&gt;linear()&lt;/code&gt; easings: &lt;code&gt;ease-spring&lt;/code&gt; with &lt;code&gt;duration-spring&lt;/code&gt; (500ms) matches a spring with stiffness 300 and damping 30.&lt;/p&gt;

&lt;h2&gt;
  
  
  Can component props work like design tokens?
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;UIProvider defaults&lt;/code&gt; works as a token layer for props. The free &lt;a href="https://wingo-ui.com/components/ui-config" rel="noopener noreferrer"&gt;UIProvider&lt;/a&gt; merges app-wide defaults under the props each component receives, keyed by its &lt;code&gt;data-slot&lt;/code&gt; name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/providers.tsx&lt;/span&gt;
&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;use client&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ReactNode&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ThemeProvider&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;next-themes&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;UIProvider&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/lib/ui-config&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Providers&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;children&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;children&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ReactNode&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ThemeProvider&lt;/span&gt; &lt;span class="na"&gt;attribute&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"class"&lt;/span&gt; &lt;span class="na"&gt;defaultTheme&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"system"&lt;/span&gt; &lt;span class="na"&gt;enableSystem&lt;/span&gt; &lt;span class="na"&gt;disableTransitionOnChange&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;UIProvider&lt;/span&gt; &lt;span class="na"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"en-US"&lt;/span&gt; &lt;span class="na"&gt;defaults&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;button&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;radius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;full&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="na"&gt;card&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;outline&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;UIProvider&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;ThemeProvider&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Inside a component, the first line reads the defaults, and the signature defaults stay the last fallback:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Button&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;buttonProps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ButtonProps&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;variant&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;solid&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tone&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;neutral&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;md&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;radius&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;md&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;rest&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useDefaults&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;button&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;buttonProps&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Explicit props win, then the provider, then the component's own defaults. An &lt;code&gt;undefined&lt;/code&gt; prop does not override a provider value, so wrappers can pass props through without erasing the theme. The same signature takes &lt;code&gt;ref&lt;/code&gt; as a regular prop, with no &lt;code&gt;forwardRef&lt;/code&gt; wrapper; our &lt;a href="https://wingo-ui.com/blog/react-19-forwardref-ref-as-prop" rel="noopener noreferrer"&gt;React 19 forwardRef migration guide&lt;/a&gt; moves older components to that pattern, TypeScript types included.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Live demo: UIProvider.&lt;/strong&gt; 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). &lt;a href="https://wingo-ui.com/blog/tailwind-v4-design-tokens" rel="noopener noreferrer"&gt;Try it live on wingo-ui.com&lt;/a&gt; or open the &lt;a href="https://wingo-ui.com/components/ui-config" rel="noopener noreferrer"&gt;UIProvider docs&lt;/a&gt;. Install: &lt;code&gt;npx wingo-ui@latest add ui-config&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  How do you add these tokens to an existing Next.js project?
&lt;/h2&gt;

&lt;p&gt;Run &lt;code&gt;npx wingo-ui@latest init&lt;/code&gt; in a project that has Tailwind CSS v4. It writes &lt;code&gt;wingo-ui.json&lt;/code&gt; and &lt;code&gt;lib/utils.ts&lt;/code&gt; (the &lt;code&gt;cn&lt;/code&gt; helper), installs &lt;code&gt;clsx&lt;/code&gt; and &lt;code&gt;tailwind-merge&lt;/code&gt;, and merges the tokens into the stylesheet that contains &lt;code&gt;@import "tailwindcss"&lt;/code&gt;. It only adds missing names and keeps your values; &lt;code&gt;add&lt;/code&gt; later brings any tokens a new component needs. The full block, 74 variables in &lt;code&gt;:root&lt;/code&gt; plus their dark values and Tailwind mappings, is served at &lt;code&gt;/r/tokens.css&lt;/code&gt;, and coding agents get the same CSS from the MCP tool &lt;code&gt;get_theme_tokens&lt;/code&gt; (see our &lt;a href="https://wingo-ui.com/blog/component-library-mcp-guide" rel="noopener noreferrer"&gt;component library MCP guide&lt;/a&gt;). The &lt;a href="https://wingo-ui.com/docs/theming" rel="noopener noreferrer"&gt;theming docs&lt;/a&gt; show the main tokens with their light and dark values, and the two dark mode setups the CLI supports: a &lt;code&gt;.dark&lt;/code&gt; class, or &lt;code&gt;prefers-color-scheme&lt;/code&gt; when your stylesheet already uses it.&lt;/p&gt;

&lt;p&gt;To restyle, open the &lt;a href="https://wingo-ui.com/theme" rel="noopener noreferrer"&gt;theme generator&lt;/a&gt;, pick a brand color, neutrals, radius, font and component defaults, check its contrast report, and apply the export:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx wingo-ui@latest theme apply ./theme.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It replaces the values of the tokens the theme sets, in light and dark, adds Tailwind mappings for new ones and prints the &lt;code&gt;UIProvider&lt;/code&gt; defaults and locale to paste into your provider.&lt;/p&gt;

&lt;p&gt;Coming from shadcn/ui: the shared names line up, but two keep different meanings. Our &lt;code&gt;--card&lt;/code&gt; is a flat gray tile where shadcn's is the card background, and our &lt;code&gt;--accent&lt;/code&gt; is an orange tint. Because &lt;code&gt;init&lt;/code&gt; keeps your existing values, a shadcn project keeps its own &lt;code&gt;--card&lt;/code&gt; and &lt;code&gt;--accent&lt;/code&gt;, so check the soft Card variant after the merge. Our &lt;a href="https://wingo-ui.com/blog/shadcn-alternatives" rel="noopener noreferrer"&gt;shadcn alternatives comparison&lt;/a&gt; covers the wider differences between the two libraries.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which mistakes break a Tailwind v4 design system?
&lt;/h2&gt;

&lt;p&gt;The same few mistakes show up in most token files:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;dark:&lt;/code&gt; classes in components.&lt;/strong&gt; A component that needs &lt;code&gt;dark:bg-zinc-900&lt;/code&gt; is missing a token.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Palette names in components.&lt;/strong&gt; &lt;code&gt;bg-blue-600&lt;/code&gt; cannot follow a theme; use a role or a tone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fill colors as text.&lt;/strong&gt; &lt;code&gt;text-destructive&lt;/code&gt; on a dark surface fails AA; use the soft foreground.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-theme values in plain &lt;code&gt;@theme&lt;/code&gt;.&lt;/strong&gt; Keep them in &lt;code&gt;:root&lt;/code&gt; and &lt;code&gt;.dark&lt;/code&gt;, mapped with &lt;code&gt;inline&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A global radius that rewrites Tailwind's scale.&lt;/strong&gt; Every pasted &lt;code&gt;rounded-lg&lt;/code&gt; changes with it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Raw z-index numbers.&lt;/strong&gt; Two &lt;code&gt;z-50&lt;/code&gt; overlays from two authors will collide.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No contrast check.&lt;/strong&gt; Tokens drift with every tweak; a check in CI catches it the same day.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where should you start?
&lt;/h2&gt;

&lt;p&gt;Run &lt;code&gt;npx wingo-ui@latest init&lt;/code&gt; in your Next.js project, then &lt;code&gt;npx wingo-ui@latest add button&lt;/code&gt;, 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 &lt;a href="https://wingo-ui.com/blog/category/nextjs-tailwind" rel="noopener noreferrer"&gt;Next.js and Tailwind CSS&lt;/a&gt; cover the component code around the tokens: the &lt;a href="https://wingo-ui.com/blog/react-19-forwardref-ref-as-prop" rel="noopener noreferrer"&gt;React 19 forwardRef migration&lt;/a&gt; and an &lt;a href="https://wingo-ui.com/blog/react-use-mobile-hook-ssr" rel="noopener noreferrer"&gt;SSR-safe React use mobile hook&lt;/a&gt; that hydrates without a mismatch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Components in this post
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://wingo-ui.com/components/button" rel="noopener noreferrer"&gt;Button&lt;/a&gt; (Free): The button every screen starts with: five variants, six tones, three sizes plus icon sizes, and a loading state that never jumps. Install: &lt;code&gt;npx wingo-ui@latest add button&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://wingo-ui.com/components/tones" rel="noopener noreferrer"&gt;Tones&lt;/a&gt; (Free): One way to color a component: a tone or any CSS color becomes five CSS variables every variant paints with. Install: &lt;code&gt;npx wingo-ui@latest add tones&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://wingo-ui.com/components/ui-config" rel="noopener noreferrer"&gt;UIProvider&lt;/a&gt; (Free): App-wide configuration for Wingo UI: default props for every component, the Intl locale and the reduced-motion policy. Install: &lt;code&gt;npx wingo-ui@latest add ui-config&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://wingo-ui.com/components/card" rel="noopener noreferrer"&gt;Card&lt;/a&gt; (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: &lt;code&gt;npx wingo-ui@latest add card&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://wingo-ui.com/components/badge" rel="noopener noreferrer"&gt;Badge&lt;/a&gt; (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: &lt;code&gt;npx wingo-ui@latest add badge&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Where do design tokens go in Tailwind v4?
&lt;/h3&gt;

&lt;p&gt;In the global CSS file, after &lt;a class="mentioned-user" href="https://dev.to/import"&gt;@import&lt;/a&gt; "tailwindcss". Variables in an &lt;a class="mentioned-user" href="https://dev.to/theme"&gt;@theme&lt;/a&gt; block become utilities (--color-surface gives bg-surface), and values that change per theme live in :root and .dark and are mapped with &lt;a class="mentioned-user" href="https://dev.to/theme"&gt;@theme&lt;/a&gt; inline. A tailwind.config.js file is no longer required.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the difference between &lt;a class="mentioned-user" href="https://dev.to/theme"&gt;@theme&lt;/a&gt; and &lt;a class="mentioned-user" href="https://dev.to/theme"&gt;@theme&lt;/a&gt; inline in Tailwind v4?
&lt;/h3&gt;

&lt;p&gt;With &lt;a class="mentioned-user" href="https://dev.to/theme"&gt;@theme&lt;/a&gt;, the utility references the theme variable, so bg-surface compiles to var(--color-surface), which is resolved once on :root. With &lt;a class="mentioned-user" href="https://dev.to/theme"&gt;@theme&lt;/a&gt; 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should Tailwind v4 colors be OKLCH or hex?
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I add dark mode to Tailwind v4 design tokens?
&lt;/h3&gt;

&lt;p&gt;Redefine the same variables in a .dark block and add @custom-variant dark (&amp;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I check color contrast for design tokens?
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Are the Wingo UI design tokens free?
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article was originally published on the Wingo UI blog: &lt;a href="https://wingo-ui.com/blog/tailwind-v4-design-tokens" rel="noopener noreferrer"&gt;Tailwind v4 Design Tokens: Colors, Dark Mode and Contrast&lt;/a&gt;. The original has the live, interactive demos and is kept up to date.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>tailwindcss</category>
      <category>css</category>
      <category>designsystem</category>
      <category>a11y</category>
    </item>
    <item>
      <title>14 shadcn Alternatives in 2026: Price, Mobile and MCP</title>
      <dc:creator>Serban Dan Rusu</dc:creator>
      <pubDate>Wed, 21 Oct 2026 07:00:00 +0000</pubDate>
      <link>https://dev.to/wingo-ui/14-shadcn-alternatives-in-2026-price-mobile-and-mcp-3pab</link>
      <guid>https://dev.to/wingo-ui/14-shadcn-alternatives-in-2026-price-mobile-and-mcp-3pab</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;a href="https://wingo-ui.com/blog/category/comparisons" rel="noopener noreferrer"&gt;Comparisons and alternatives&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is the best alternative to shadcn UI?
&lt;/h2&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Keep the model, change the components.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Leave the model, install a package.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the primitives, drop the look.&lt;/strong&gt; Base UI, React Aria Components or Radix Primitives, styled by you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Step outside React.&lt;/strong&gt; daisyUI, which is a set of Tailwind CSS class names that works with any framework.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If none of those reasons applies to you, the best option may be staying where you are, which the section near the end covers.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed in shadcn/ui in 2026?
&lt;/h2&gt;

&lt;p&gt;The biggest change: since July 2026, new shadcn/ui projects start on Base UI instead of Radix. The &lt;a href="https://ui.shadcn.com/docs/changelog/2026-07-base-ui-default" rel="noopener noreferrer"&gt;July 2026 changelog entry&lt;/a&gt; made Base UI the default for &lt;code&gt;npx shadcn init&lt;/code&gt; and for the docs, kept Radix fully supported behind &lt;code&gt;npx shadcn init -b radix&lt;/code&gt;, and noted that projects created on shadcn/create were already picking Base UI over Radix 2 to 1. The same month, &lt;a href="https://ui.shadcn.com/docs/changelog/2026-07-react-aria" rel="noopener noreferrer"&gt;React Aria became a third base&lt;/a&gt; (&lt;code&gt;--base aria&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Other changes that matter when you compare:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;a href="https://ui.shadcn.com/docs/components/drawer" rel="noopener noreferrer"&gt;Drawer&lt;/a&gt; in Base UI projects now uses Base UI's drawer instead of Vaul. The Radix version still uses Vaul.&lt;/li&gt;
&lt;li&gt;June 2026 added components for chat interfaces, July 2026 a Toast built on Base UI, and September 2026 moved &lt;code&gt;cn&lt;/code&gt; into &lt;a href="https://ui.shadcn.com/docs/changelog/2026-09-cn" rel="noopener noreferrer"&gt;its own package&lt;/a&gt;, so components import it from &lt;code&gt;cn&lt;/code&gt; instead of &lt;code&gt;@/lib/utils&lt;/code&gt;. What the new Toast means next to Sonner is in our &lt;a href="https://wingo-ui.com/blog/sonner-alternative-react-toast" rel="noopener noreferrer"&gt;Sonner alternatives comparison&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;On the agent side there is an &lt;a href="https://ui.shadcn.com/docs/mcp" rel="noopener noreferrer"&gt;MCP server&lt;/a&gt; that browses, searches and installs from every registry in &lt;code&gt;components.json&lt;/code&gt;, and a &lt;a href="https://ui.shadcn.com/docs/skills" rel="noopener noreferrer"&gt;skill&lt;/a&gt; (&lt;code&gt;npx skills add shadcn/ui&lt;/code&gt;) that reads your project configuration before the agent writes code.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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 &lt;code&gt;shadcn add&lt;/code&gt; command, so trying an alternative no longer means learning a new tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do the shadcn alternatives compare?
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Libraries that put the source in your project
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Library&lt;/th&gt;
&lt;th&gt;Model and base&lt;/th&gt;
&lt;th&gt;License&lt;/th&gt;
&lt;th&gt;Price&lt;/th&gt;
&lt;th&gt;Mobile&lt;/th&gt;
&lt;th&gt;Agents&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://ui.shadcn.com/docs/changelog" rel="noopener noreferrer"&gt;shadcn/ui&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;CLI copies source; Base UI (default), Radix or React Aria&lt;/td&gt;
&lt;td&gt;MIT&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;td&gt;Drawer, a Dialog plus Drawer recipe, Sidebar with its own mobile state&lt;/td&gt;
&lt;td&gt;MCP server, skill, llms.txt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://coss.com/ui/llms.txt" rel="noopener noreferrer"&gt;coss ui&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;shadcn registry (&lt;code&gt;@coss&lt;/code&gt;); Base UI&lt;/td&gt;
&lt;td&gt;MIT&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;td&gt;Drawer with swipe, snap points and nesting&lt;/td&gt;
&lt;td&gt;llms.txt, agent skills&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://pro.magicui.design" rel="noopener noreferrer"&gt;Magic UI&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;shadcn registry (&lt;code&gt;@magicui&lt;/code&gt;); Motion&lt;/td&gt;
&lt;td&gt;MIT (free), paid license (Pro)&lt;/td&gt;
&lt;td&gt;Free; Pro $199 one-time&lt;/td&gt;
&lt;td&gt;Landing page effects; no sheet, popover or menu in the catalog&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://magicui.design/docs/mcp" rel="noopener noreferrer"&gt;MCP server&lt;/a&gt;, llms.txt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://ui.aceternity.com/pricing" rel="noopener noreferrer"&gt;Aceternity UI&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;shadcn registry (&lt;code&gt;@aceternity&lt;/code&gt;); Motion&lt;/td&gt;
&lt;td&gt;&lt;a href="https://ui.aceternity.com/licence" rel="noopener noreferrer"&gt;Aceternity License&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Free; $169 a year or $199 lifetime; Team $1,590 for 10 people&lt;/td&gt;
&lt;td&gt;Effects and blocks; some blocks document a mobile menu&lt;/td&gt;
&lt;td&gt;llms.txt; prompts for v0 and Lovable on paid plans&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://www.untitledui.com/react" rel="noopener noreferrer"&gt;Untitled UI React&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Own CLI or npm; React Aria, Tailwind CSS v4&lt;/td&gt;
&lt;td&gt;MIT (free base), PRO license&lt;/td&gt;
&lt;td&gt;Free; PRO from $349 one-time for one user&lt;/td&gt;
&lt;td&gt;Drawers and modals are PRO&lt;/td&gt;
&lt;td&gt;MCP server, llms.txt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://wingo-ui.com/docs/installation" rel="noopener noreferrer"&gt;Wingo UI&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Own CLI or shadcn registry (&lt;code&gt;@wingo-ui&lt;/code&gt;); Radix, vaul, Motion&lt;/td&gt;
&lt;td&gt;Per-developer license; 75 items free&lt;/td&gt;
&lt;td&gt;Free; Pro $8 a month, $80 a year or $150 lifetime&lt;/td&gt;
&lt;td&gt;Popover, select, menus and dialog open as bottom sheets below 768px; 44px targets on touch; table rows become cards&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://wingo-ui.com/docs/mcp" rel="noopener noreferrer"&gt;MCP server&lt;/a&gt;, rules for 5 agents, llms.txt, update checks&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Packages and primitives
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Library&lt;/th&gt;
&lt;th&gt;Model and base&lt;/th&gt;
&lt;th&gt;License&lt;/th&gt;
&lt;th&gt;Price&lt;/th&gt;
&lt;th&gt;Mobile&lt;/th&gt;
&lt;th&gt;Agents&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://mantine.dev/guides/llms/" rel="noopener noreferrer"&gt;Mantine 9&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;npm; own CSS, no Tailwind&lt;/td&gt;
&lt;td&gt;MIT&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;td&gt;Modal goes full screen on small screens with &lt;code&gt;use-media-query&lt;/code&gt; (a docs recipe)&lt;/td&gt;
&lt;td&gt;MCP server, skills, llms.txt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://mui.com/pricing/" rel="noopener noreferrer"&gt;MUI (Material UI 9)&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;npm; Emotion&lt;/td&gt;
&lt;td&gt;MIT core&lt;/td&gt;
&lt;td&gt;Free; MUI X Pro $299 or Premium $599 per developer a year&lt;/td&gt;
&lt;td&gt;SwipeableDrawer; Dialog goes full screen with &lt;code&gt;useMediaQuery&lt;/code&gt; (a docs recipe)&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://mui.com/material-ui/getting-started/mcp/" rel="noopener noreferrer"&gt;MCP server&lt;/a&gt;, llms.txt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://www.chakra-ui.com/docs/get-started/ai/mcp-server" rel="noopener noreferrer"&gt;Chakra UI 3&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;npm; Ark UI, Emotion&lt;/td&gt;
&lt;td&gt;MIT&lt;/td&gt;
&lt;td&gt;Free; &lt;a href="https://pro.chakra-ui.com/pricing" rel="noopener noreferrer"&gt;Chakra UI Pro&lt;/a&gt; blocks $299 one-time&lt;/td&gt;
&lt;td&gt;Drawer placement takes breakpoints, such as bottom on phones and end from &lt;code&gt;md&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;MCP server, llms.txt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://www.heroui.com/llms.txt" rel="noopener noreferrer"&gt;HeroUI 3&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;npm; React Aria, Tailwind CSS v4&lt;/td&gt;
&lt;td&gt;MIT&lt;/td&gt;
&lt;td&gt;Free; &lt;a href="https://heroui.pro/pricing" rel="noopener noreferrer"&gt;HeroUI Pro&lt;/a&gt; sold separately&lt;/td&gt;
&lt;td&gt;Modal placement &lt;code&gt;auto&lt;/code&gt; sits at the bottom on mobile and centered on desktop; a separate React Native library&lt;/td&gt;
&lt;td&gt;MCP server, agent skills, llms.txt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://ant.design/docs/react/mcp" rel="noopener noreferrer"&gt;Ant Design 6&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;npm; own design language&lt;/td&gt;
&lt;td&gt;MIT&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;td&gt;Built for enterprise back offices; phones get a separate library, antd-mobile&lt;/td&gt;
&lt;td&gt;MCP server (&lt;code&gt;antd mcp&lt;/code&gt;), llms.txt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://daisyui.com/components/modal/" rel="noopener noreferrer"&gt;daisyUI 5&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Tailwind CSS plugin, class names only&lt;/td&gt;
&lt;td&gt;MIT&lt;/td&gt;
&lt;td&gt;Free; &lt;a href="https://daisyui.com/blueprint/" rel="noopener noreferrer"&gt;Blueprint MCP&lt;/a&gt; $22 a month or $600 lifetime&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;modal-bottom sm:modal-middle&lt;/code&gt; puts a modal at the bottom on phones; Dock for bottom navigation&lt;/td&gt;
&lt;td&gt;llms.txt, skill; Blueprint MCP (paid)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://konstaui.com/" rel="noopener noreferrer"&gt;Konsta UI 5&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;npm for React, Vue and Svelte; Tailwind CSS&lt;/td&gt;
&lt;td&gt;MIT&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;td&gt;Mobile only: iOS and Material sheets, tab bars and toolbars&lt;/td&gt;
&lt;td&gt;None found&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://base-ui.com/llms.txt" rel="noopener noreferrer"&gt;Base UI 1.8&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;npm; unstyled&lt;/td&gt;
&lt;td&gt;MIT&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;td&gt;Drawer with swipe to dismiss&lt;/td&gt;
&lt;td&gt;llms.txt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://react-aria.adobe.com/llms.txt" rel="noopener noreferrer"&gt;React Aria Components&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;npm; unstyled&lt;/td&gt;
&lt;td&gt;Apache-2.0&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;td&gt;Press and hover events normalized for touch&lt;/td&gt;
&lt;td&gt;MCP server, agent skills, llms.txt&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;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 &lt;a href="https://tailwindcss.com/blog/tailwind-plus" rel="noopener noreferrer"&gt;one-time purchase with lifetime access&lt;/a&gt;, 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which alternatives keep the shadcn model?
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;coss ui&lt;/strong&gt; 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 &lt;a href="https://github.com/cosscom/coss" rel="noopener noreferrer"&gt;repository&lt;/a&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Magic UI and Aceternity UI&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Untitled UI React&lt;/strong&gt; is the code side of the Untitled UI Figma kit, installed with its own CLI (&lt;code&gt;npx untitledui@latest add&lt;/code&gt;) 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Wingo UI&lt;/strong&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which alternatives ship as an npm package?
&lt;/h2&gt;

&lt;p&gt;Mantine, MUI, Chakra UI, HeroUI and Ant Design. You upgrade with &lt;code&gt;npm update&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mantine 9&lt;/strong&gt; 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 &lt;a href="https://mantine.dev/guides/llms/" rel="noopener noreferrer"&gt;AI guide&lt;/a&gt; documents llms.txt, skills and an MCP server whose &lt;code&gt;get_item_props&lt;/code&gt; tool returns a component's props or a hook's signature.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MUI&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Chakra UI 3&lt;/strong&gt; rebuilt the library on Ark UI and kept its style props. Its responsive object syntax reaches component props too: the Drawer docs show &lt;code&gt;placement={{ mdDown: "bottom", md: "end" }}&lt;/code&gt;, which opens a bottom drawer on phones and a side panel from &lt;code&gt;md&lt;/code&gt;. Chakra UI Pro sells blocks on top, from $299 one-time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;HeroUI 3&lt;/strong&gt; is the closest npm package to the shadcn stack: React Aria Components underneath, Tailwind CSS v4 for styling, MIT. Its Modal placement defaults to &lt;code&gt;auto&lt;/code&gt;, which sits at the bottom of the screen below the &lt;code&gt;sm&lt;/code&gt; breakpoint and centered above it, and its &lt;a href="https://github.com/heroui-inc/heroui/blob/v3/packages/styles/components/button.css" rel="noopener noreferrer"&gt;button styles&lt;/a&gt; make the default size 40px tall on phones and 36px from &lt;code&gt;md&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ant Design 6&lt;/strong&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should you use headless primitives instead?
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Base UI&lt;/strong&gt; reached &lt;a href="https://base-ui.com/llms.txt" rel="noopener noreferrer"&gt;v1.0.0 on December 11, 2025&lt;/a&gt; and is now the default base under shadcn/ui. Its Drawer handles swipe to dismiss, the job shadcn/ui used Vaul for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;React Aria Components&lt;/strong&gt; from Adobe has the most thorough interaction layer we know of. &lt;code&gt;usePress&lt;/code&gt; handles mouse, touch, keyboard and screen readers through one API, and &lt;code&gt;useHover&lt;/code&gt; &lt;a href="https://react-aria.adobe.com/useHover.md" rel="noopener noreferrer"&gt;ignores the emulated mouse events that touch devices fire&lt;/a&gt;, which is the cause of hover styles that stick after a tap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Radix Primitives&lt;/strong&gt; is still maintained and still under most of the shadcn ecosystem, including Wingo UI.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which shadcn alternatives work best on mobile?
&lt;/h2&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Responsive CSS only.&lt;/strong&gt; Magic UI and Aceternity UI (effects for landing pages) and Ant Design (a separate mobile library).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A recipe you copy per component.&lt;/strong&gt; shadcn/ui, MUI and Mantine document a pattern that reads a media query and renders a different component or a full-screen variant.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A placement that changes by breakpoint.&lt;/strong&gt; Chakra UI's drawer placement, daisyUI's &lt;code&gt;modal-bottom sm:modal-middle&lt;/code&gt; and HeroUI's &lt;code&gt;auto&lt;/code&gt; placement move the panel to the bottom of the screen on phones. Whether you can drag it away depends on the component.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Built into each component.&lt;/strong&gt; Konsta UI, for mobile-only apps with an iOS or Material look, and Wingo UI, where the &lt;a href="https://wingo-ui.com/components/popover" rel="noopener noreferrer"&gt;Popover&lt;/a&gt;, &lt;a href="https://wingo-ui.com/components/select" rel="noopener noreferrer"&gt;Select&lt;/a&gt;, Dropdown Menu, &lt;a href="https://wingo-ui.com/components/dialog" rel="noopener noreferrer"&gt;Dialog&lt;/a&gt; and date pickers open as draggable bottom sheets below 768px through a &lt;code&gt;responsive&lt;/code&gt; prop that defaults to &lt;code&gt;true&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isDesktop&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useMediaQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;(min-width: 768px)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isDesktop&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Dialog&lt;/span&gt; &lt;span class="na"&gt;open&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;open&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;onOpenChange&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;setOpen&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* trigger, header and &amp;lt;ProfileForm /&amp;gt; */&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Dialog&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Drawer&lt;/span&gt; &lt;span class="na"&gt;open&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;open&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;onOpenChange&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;setOpen&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* the same trigger and header again, then &amp;lt;ProfileForm /&amp;gt; */&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Drawer&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That 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 &lt;code&gt;false&lt;/code&gt; on the server. Our &lt;a href="https://wingo-ui.com/blog/mobile-first-react-components" rel="noopener noreferrer"&gt;mobile-first React components guide&lt;/a&gt; covers why that hook value is harmless for overlays and harmful for content, along with the other rules for phones.&lt;/p&gt;

&lt;p&gt;The level 4 version of the same idea is one component. The free &lt;a href="https://wingo-ui.com/components/drawer" rel="noopener noreferrer"&gt;Drawer&lt;/a&gt; is a draggable bottom sheet on phones, and its &lt;code&gt;desktop&lt;/code&gt; prop decides what it becomes from 768px: the same sheet (&lt;code&gt;"sheet"&lt;/code&gt;, the default), a side panel (&lt;code&gt;"side"&lt;/code&gt;) or a centered dialog (&lt;code&gt;"dialog"&lt;/code&gt;).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;use client&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;SlidersHorizontal&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;lucide-react&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Button&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/components/ui/button&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Drawer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;DrawerClose&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/components/ui/drawer&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ListingFilters&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;results&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Drawer&lt;/span&gt;
      &lt;span class="na"&gt;trigger&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt; &lt;span class="na"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"soft"&lt;/span&gt; &lt;span class="na"&gt;leftIcon&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;SlidersHorizontal&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
          Filters
        &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Filters"&lt;/span&gt;
      &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"New York · 1,240 listings"&lt;/span&gt;
      &lt;span class="na"&gt;desktop&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"dialog"&lt;/span&gt;
      &lt;span class="na"&gt;closeLabel&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Close filters"&lt;/span&gt;
      &lt;span class="na"&gt;footer&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;DrawerClose&lt;/span&gt; &lt;span class="na"&gt;asChild&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt; &lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"lg"&lt;/span&gt; &lt;span class="na"&gt;fullWidth&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
            Show &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; results
          &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;DrawerClose&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"text-sm text-muted-foreground"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Price, condition and categories&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Drawer&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Live demo: Drawer.&lt;/strong&gt; 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). &lt;a href="https://wingo-ui.com/blog/shadcn-alternatives" rel="noopener noreferrer"&gt;Try it live on wingo-ui.com&lt;/a&gt; or open the &lt;a href="https://wingo-ui.com/components/drawer" rel="noopener noreferrer"&gt;Drawer docs&lt;/a&gt;. Install: &lt;code&gt;npx wingo-ui@latest add drawer&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;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 &lt;code&gt;closeLabel&lt;/code&gt; is its accessible name.&lt;/p&gt;

&lt;p&gt;Touch targets follow the same split. HeroUI v3 makes its buttons taller below &lt;code&gt;md&lt;/code&gt;, as noted above. Wingo UI's &lt;a href="https://wingo-ui.com/components/button" rel="noopener noreferrer"&gt;Button&lt;/a&gt; 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 &lt;a href="https://wingo-ui.com/blog/best-react-component-library-for-mobile" rel="noopener noreferrer"&gt;best React component library for mobile&lt;/a&gt; comparison checks nine libraries at 390px, Ionic React and antd-mobile included, on sheets, touch targets, safe areas and gestures.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which shadcn alternatives work best with coding agents?
&lt;/h2&gt;

&lt;p&gt;In October 2026 most of them have an MCP server, so the useful question is what the server can do. We see three kinds:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Documentation servers&lt;/strong&gt; answer questions about components, props and examples, and npm still installs the package. MUI, Mantine (&lt;code&gt;search_docs&lt;/code&gt;, &lt;code&gt;get_item_props&lt;/code&gt;, &lt;code&gt;get_api&lt;/code&gt;), Chakra UI (&lt;code&gt;list_components&lt;/code&gt;, &lt;code&gt;get_component_props&lt;/code&gt;, &lt;code&gt;get_theme&lt;/code&gt;), Ant Design (eight tools behind &lt;code&gt;antd mcp&lt;/code&gt;, &lt;code&gt;antd_changelog&lt;/code&gt; among them), React Aria and HeroUI work this way.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Install servers&lt;/strong&gt; find items and put their source into your project. The shadcn MCP server does it through the shadcn CLI for any registry in &lt;code&gt;components.json&lt;/code&gt;, and Untitled UI's server can browse, search and install its components. Magic UI's gives the agent direct access to its components.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Servers that also check for updates.&lt;/strong&gt; 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 &lt;code&gt;check_updates&lt;/code&gt; tool compares the versions in your &lt;code&gt;wingo-ui.json&lt;/code&gt; with the latest release, so the agent hears about fixes before it edits a file that has one waiting.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Setting each one up takes one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# documentation servers: the agent reads props and examples, npm installs the package&lt;/span&gt;
claude mcp add mantine &lt;span class="nt"&gt;--&lt;/span&gt; npx &lt;span class="nt"&gt;-y&lt;/span&gt; @mantine/mcp-server
claude mcp add chakra-ui &lt;span class="nt"&gt;--&lt;/span&gt; npx &lt;span class="nt"&gt;-y&lt;/span&gt; @chakra-ui/react-mcp

&lt;span class="c"&gt;# an install server for every registry in components.json&lt;/span&gt;
npx shadcn@latest mcp init &lt;span class="nt"&gt;--client&lt;/span&gt; claude

&lt;span class="c"&gt;# the Wingo UI MCP server plus a rules file for the agent&lt;/span&gt;
npx wingo-ui@latest agents &lt;span class="nb"&gt;install &lt;/span&gt;claude
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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 &lt;a href="https://wingo-ui.com/blog/component-library-mcp-guide" rel="noopener noreferrer"&gt;guide to component library MCP servers&lt;/a&gt; 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 &lt;a href="https://wingo-ui.com/blog/v0-vs-claude-code-for-ui" rel="noopener noreferrer"&gt;v0 vs Claude Code comparison&lt;/a&gt; weighs code ownership, design consistency and cost across sign-in, pricing and settings screens.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you update components you copied?
&lt;/h2&gt;

&lt;p&gt;With a package, you run &lt;code&gt;npm update&lt;/code&gt; and read the changelog. With copied source, the merge is yours, and libraries differ in how much they help.&lt;/p&gt;

&lt;p&gt;The shadcn CLI can preview: &lt;code&gt;npx shadcn@latest add dialog --dry-run&lt;/code&gt; lists what would change and &lt;code&gt;--diff&lt;/code&gt; 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 &lt;a href="https://wingo-ui.com/blog/update-shadcn-components-without-losing-edits" rel="noopener noreferrer"&gt;updating shadcn components without losing edits&lt;/a&gt; turns that manual merge into a git 3-way merge.&lt;/p&gt;

&lt;p&gt;Wingo UI's CLI records the version of everything it installs in &lt;code&gt;wingo-ui.json&lt;/code&gt; and keeps the installed copy in &lt;code&gt;.wingo-ui/base/&lt;/code&gt;, which lets it run a three-way merge, the same idea as &lt;code&gt;git merge&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx wingo-ui@latest outdated              &lt;span class="c"&gt;# installed vs latest, and which files you edited&lt;/span&gt;
npx wingo-ui@latest diff drawer &lt;span class="nt"&gt;--base&lt;/span&gt;    &lt;span class="c"&gt;# what you changed since installing&lt;/span&gt;
npx wingo-ui@latest update drawer         &lt;span class="c"&gt;# untouched files are replaced, your edits are kept&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When upstream and you changed the same lines, the file gets conflict markers and you choose, or you settle every conflict with &lt;code&gt;--theirs&lt;/code&gt; or &lt;code&gt;--ours&lt;/code&gt;. The &lt;a href="https://wingo-ui.com/docs/updates" rel="noopener noreferrer"&gt;updates docs&lt;/a&gt; cover the details. One caveat: files you install through the shadcn CLI from our registry carry no version record, so &lt;code&gt;wingo-ui update&lt;/code&gt; cannot merge into them later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which shadcn alternatives include a data table?
&lt;/h2&gt;

&lt;p&gt;MUI, Ant Design and Wingo UI ship a full data table; shadcn/ui gives you a guide instead. Its &lt;a href="https://ui.shadcn.com/docs/components/data-table" rel="noopener noreferrer"&gt;Data Table page&lt;/a&gt; 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 &lt;a href="https://wingo-ui.com/blog/tanstack-table-v9-data-table" rel="noopener noreferrer"&gt;TanStack Table v9 data table tutorial&lt;/a&gt; builds that code with filters, bulk actions and server pagination, and compares it with the ready-made tables.&lt;/p&gt;

&lt;p&gt;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 &lt;a href="https://wingo-ui.com/components/data-table" rel="noopener noreferrer"&gt;Data Table&lt;/a&gt; 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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;use client&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;DataTable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;createColumns&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/components/ui/data-table&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;city&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;total&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

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

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Orders&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;DataTable&lt;/span&gt;
      &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;orders&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;columns&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;columns&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;caption&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Orders"&lt;/span&gt;
      &lt;span class="na"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"en-US"&lt;/span&gt;
      &lt;span class="na"&gt;selectionMode&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"multiple"&lt;/span&gt;
      &lt;span class="na"&gt;searchPlaceholder&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Search orders"&lt;/span&gt;
      &lt;span class="na"&gt;mobileLayout&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"cards"&lt;/span&gt;
    &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Live demo: Data Table.&lt;/strong&gt; 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). &lt;a href="https://wingo-ui.com/blog/shadcn-alternatives" rel="noopener noreferrer"&gt;Try it live on wingo-ui.com&lt;/a&gt; or open the &lt;a href="https://wingo-ui.com/components/data-table" rel="noopener noreferrer"&gt;Data Table docs&lt;/a&gt;. Install: &lt;code&gt;npx wingo-ui@latest add data-table&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The &lt;code&gt;mobile&lt;/code&gt; key on a column decides where its value goes on the phone card: &lt;code&gt;"title"&lt;/code&gt;, &lt;code&gt;"subtitle"&lt;/code&gt;, &lt;code&gt;"meta"&lt;/code&gt;, &lt;code&gt;"field"&lt;/code&gt; or &lt;code&gt;"hidden"&lt;/code&gt;. Rows need a unique &lt;code&gt;id&lt;/code&gt; (or pass &lt;code&gt;getRowId&lt;/code&gt;), because selection uses it. Switch &lt;code&gt;mobileLayout&lt;/code&gt; to &lt;code&gt;"scroll"&lt;/code&gt; when a table has to stay a table.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which shadcn UI alternative for React fits your project?
&lt;/h2&gt;

&lt;p&gt;Match the library to the job. These are our picks, including the many cases where the answer is not ours:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Enterprise admin with heavy grids and date ranges:&lt;/strong&gt; MUI with MUI X, the deepest set of data components in this list.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Internal tools with complex forms, without Tailwind:&lt;/strong&gt; Mantine. Free, MIT, with forms and dates built in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A design system of your own:&lt;/strong&gt; Base UI or React Aria Components. React Aria if touch and screen reader behavior has to be exact.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A free shadcn alternative on the new default base:&lt;/strong&gt; coss ui.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An npm package with the shadcn look and stack:&lt;/strong&gt; HeroUI 3.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Animated marketing pages:&lt;/strong&gt; Magic UI or Aceternity UI, next to whatever you use for the app.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Server-rendered pages with little JavaScript, or a stack beyond React:&lt;/strong&gt; daisyUI.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A mobile-only web app, or one wrapped in Capacitor, with a native iOS or Material look:&lt;/strong&gt; Konsta UI.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A product that people use on phones and desktops, built with coding agents, where you own the source and still want updates:&lt;/strong&gt; Wingo UI.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  When should you stay on shadcn/ui?
&lt;/h2&gt;

&lt;p&gt;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 &lt;a href="https://github.com/shadcn-ui/ui" rel="noopener noreferrer"&gt;GitHub repository&lt;/a&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you switch without a rewrite?
&lt;/h2&gt;

&lt;p&gt;Replace one component at a time, starting with the ones that hurt most, and keep both libraries in the project while you do.&lt;/p&gt;

&lt;p&gt;Three practical points for a move from shadcn/ui to Wingo UI, most of which apply to any registry:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;File names collide.&lt;/strong&gt; Both libraries write &lt;code&gt;components/ui/button.tsx&lt;/code&gt;, &lt;code&gt;dialog.tsx&lt;/code&gt; and &lt;code&gt;drawer.tsx&lt;/code&gt;. Our CLI keeps an existing file that differs and tells you to rerun with &lt;code&gt;--overwrite&lt;/code&gt;, so commit first and swap deliberately.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Call sites change.&lt;/strong&gt; shadcn/ui's Button uses &lt;code&gt;variant="destructive"&lt;/code&gt; and &lt;code&gt;variant="secondary"&lt;/code&gt;; ours separates the role from the color, so those become &lt;code&gt;tone="danger"&lt;/code&gt; and &lt;code&gt;variant="soft"&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pick the right CLI for a Base UI project.&lt;/strong&gt; Free items install from their URL with &lt;code&gt;npx shadcn@latest add https://wingo-ui.com/r/button.json&lt;/code&gt;, and Pro items through the &lt;code&gt;@wingo-ui&lt;/code&gt; registry in &lt;code&gt;components.json&lt;/code&gt;. But shadcn projects created with a Base UI style rewrite &lt;code&gt;asChild&lt;/code&gt;, which our components rely on, so in those projects use &lt;code&gt;npx wingo-ui@latest add&lt;/code&gt; instead, as the &lt;a href="https://wingo-ui.com/docs/installation" rel="noopener noreferrer"&gt;installation docs&lt;/a&gt; explain.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;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 &lt;code&gt;radius="md"&lt;/code&gt;, which gives the medium size 10px corners; if your current controls use 8px, &lt;code&gt;"sm"&lt;/code&gt; matches them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;UIProvider&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/lib/ui-config&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;RootLayout&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;children&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;children&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;React&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ReactNode&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;html&lt;/span&gt; &lt;span class="na"&gt;lang&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"en"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;body&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;UIProvider&lt;/span&gt; &lt;span class="na"&gt;defaults&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;button&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;radius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sm&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;radius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sm&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;UIProvider&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;body&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;html&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;UIProvider&lt;/code&gt; 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.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Live demo: Button.&lt;/strong&gt; 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). &lt;a href="https://wingo-ui.com/blog/shadcn-alternatives" rel="noopener noreferrer"&gt;Try it live on wingo-ui.com&lt;/a&gt; or open the &lt;a href="https://wingo-ui.com/components/button" rel="noopener noreferrer"&gt;Button docs&lt;/a&gt;. Install: &lt;code&gt;npx wingo-ui@latest add button&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Where should you start?
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;npx wingo-ui@latest add drawer&lt;/code&gt; (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 &lt;a href="https://wingo-ui.com/pricing" rel="noopener noreferrer"&gt;Wingo UI Pro&lt;/a&gt;, and the &lt;a href="https://wingo-ui.com/docs/mcp" rel="noopener noreferrer"&gt;MCP server&lt;/a&gt; answers questions about every item without an account, so your agent can read the props before you decide.&lt;/p&gt;

&lt;h2&gt;
  
  
  Components in this post
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://wingo-ui.com/components/button" rel="noopener noreferrer"&gt;Button&lt;/a&gt; (Free): The button every screen starts with: five variants, six tones, three sizes plus icon sizes, and a loading state that never jumps. Install: &lt;code&gt;npx wingo-ui@latest add button&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://wingo-ui.com/components/drawer" rel="noopener noreferrer"&gt;Drawer&lt;/a&gt; (Free): The library's bottom sheet on vaul: drag to dismiss, snap points, nesting, and a side panel or dialog on desktop. Install: &lt;code&gt;npx wingo-ui@latest add drawer&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://wingo-ui.com/components/data-table" rel="noopener noreferrer"&gt;Data Table&lt;/a&gt; (Pro): The admin table on TanStack Table v9: search, filters, sorting, bulk actions, pinning, resizing, virtualization and cards on phones. Install: &lt;code&gt;npx wingo-ui@latest add data-table&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What is the best free alternative to shadcn/ui?
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is shadcn/ui still built on Radix?
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which shadcn alternative works best on mobile?
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I use a shadcn alternative alongside shadcn/ui?
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do shadcn alternatives work with Claude Code and Cursor?
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article was originally published on the Wingo UI blog: &lt;a href="https://wingo-ui.com/blog/shadcn-alternatives" rel="noopener noreferrer"&gt;14 shadcn Alternatives in 2026: Price, Mobile and MCP&lt;/a&gt;. The original has the live, interactive demos and is kept up to date.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>react</category>
      <category>shadcn</category>
      <category>tailwindcss</category>
      <category>webdev</category>
    </item>
    <item>
      <title>React Form Components: Accessible Fields, Zod and Mobile</title>
      <dc:creator>Serban Dan Rusu</dc:creator>
      <pubDate>Tue, 20 Oct 2026 15:00:00 +0000</pubDate>
      <link>https://dev.to/wingo-ui/react-form-components-accessible-fields-zod-and-mobile-3l1i</link>
      <guid>https://dev.to/wingo-ui/react-form-components-accessible-fields-zod-and-mobile-3l1i</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;Good React form components share one anatomy: a label tied to the control, one message row that shows the hint or the error, and a counter when there is a limit, with the ids and aria attributes wired for you. Connect them to react-hook-form with register for native inputs and Controller for everything else, and validate with one zod schema once a field has been left. On phones, every control needs 16px text, the right keypad, 44px targets and a bottom sheet instead of a popover.&lt;/p&gt;

&lt;p&gt;Every product ships the same handful of forms: sign up, checkout, settings, a filter panel, a support request. The controls differ, the bugs repeat. A label that is not tied to its input, an error that shows up only as a red border, a date picker that opens a small popover under your thumb, a custom select that the browser's autofill skips. This guide treats React form components as one set: which controls a product needs, the anatomy they should share, how to wire them to react-hook-form and zod, and what each one should do on a phone. The examples use Wingo UI, the library we build, so we are not neutral; outside facts link to their sources and are dated.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which form controls does a product actually need?
&lt;/h2&gt;

&lt;p&gt;The fifteen kinds of input in the table below cover the forms most products ship. Pick each one by what the person is entering, then check what it does on a phone. Most React input components look alike on a desktop. They split apart at 390px, where a popover has no room and a 32px target is too small for a thumb.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What people enter&lt;/th&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;On a phone&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Short text, email, URL&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://wingo-ui.com/components/input" rel="noopener noreferrer"&gt;Input&lt;/a&gt; (Free)&lt;/td&gt;
&lt;td&gt;16px text, keypad from the &lt;code&gt;type&lt;/code&gt;, 44px box&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Long text&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://wingo-ui.com/components/textarea" rel="noopener noreferrer"&gt;Textarea&lt;/a&gt; (Free)&lt;/td&gt;
&lt;td&gt;Grows line by line, return makes a new line&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One of 2 to 5 visible options&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://wingo-ui.com/components/radio-group" rel="noopener noreferrer"&gt;Radio Group&lt;/a&gt;, &lt;a href="https://wingo-ui.com/components/segmented-control" rel="noopener noreferrer"&gt;Segmented Control&lt;/a&gt; (Free)&lt;/td&gt;
&lt;td&gt;44px targets, a radio's whole row is one&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One of a known list&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://wingo-ui.com/components/select" rel="noopener noreferrer"&gt;Select&lt;/a&gt; (Free)&lt;/td&gt;
&lt;td&gt;Bottom sheet with 48px rows&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One of thousands, or from a server&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://wingo-ui.com/components/combobox" rel="noopener noreferrer"&gt;Combobox&lt;/a&gt; (Pro)&lt;/td&gt;
&lt;td&gt;Full-height sheet with its own search box&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Several of a list&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://wingo-ui.com/components/multi-select" rel="noopener noreferrer"&gt;Multi-select&lt;/a&gt; (Pro)&lt;/td&gt;
&lt;td&gt;Sheet with a "Done (3)" footer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Consent to terms&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://wingo-ui.com/components/checkbox" rel="noopener noreferrer"&gt;Checkbox&lt;/a&gt; (Free)&lt;/td&gt;
&lt;td&gt;The whole row is a 44px target&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A setting that applies at once&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://wingo-ui.com/components/switch" rel="noopener noreferrer"&gt;Switch&lt;/a&gt; (Free)&lt;/td&gt;
&lt;td&gt;Drags like the iOS switch&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A number or an amount&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://wingo-ui.com/components/number-input" rel="noopener noreferrer"&gt;Number Input&lt;/a&gt;, &lt;a href="https://wingo-ui.com/components/currency-input" rel="noopener noreferrer"&gt;Currency Input&lt;/a&gt; (Pro)&lt;/td&gt;
&lt;td&gt;Numeric or decimal keypad, 44px steppers or amount chips&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A date&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://wingo-ui.com/components/date-picker" rel="noopener noreferrer"&gt;Date Picker&lt;/a&gt; (Pro)&lt;/td&gt;
&lt;td&gt;No keyboard: a sheet with 44px days&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A time of day&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://wingo-ui.com/components/time-picker" rel="noopener noreferrer"&gt;Time Picker&lt;/a&gt; (Pro)&lt;/td&gt;
&lt;td&gt;Wheels in a sheet&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A phone number&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://wingo-ui.com/components/phone-input" rel="noopener noreferrer"&gt;Phone Input&lt;/a&gt; (Pro)&lt;/td&gt;
&lt;td&gt;Phone keypad, country list in a sheet&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A one-time code&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://wingo-ui.com/components/otp-input" rel="noopener noreferrer"&gt;OTP Input&lt;/a&gt; (Free)&lt;/td&gt;
&lt;td&gt;Numeric keypad, code autofill&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A password&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://wingo-ui.com/components/password-input" rel="noopener noreferrer"&gt;Password Input&lt;/a&gt; (Pro)&lt;/td&gt;
&lt;td&gt;44px reveal button&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Files and photos&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://wingo-ui.com/components/file-upload" rel="noopener noreferrer"&gt;File Upload&lt;/a&gt; (Pro)&lt;/td&gt;
&lt;td&gt;"Tap to choose files", an optional camera button&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In the catalog these sit in two categories, &lt;a href="https://wingo-ui.com/components/category/inputs" rel="noopener noreferrer"&gt;Inputs&lt;/a&gt; with 36 items and &lt;a href="https://wingo-ui.com/components/category/forms" rel="noopener noreferrer"&gt;Forms&lt;/a&gt; with 8, as of release 1.7.0. Two choices in this table are easy to get wrong, so here is where we land.&lt;/p&gt;

&lt;p&gt;Choosing one option is three different controls. If people should compare the options at a glance (shipping speed, plan, size), show them all with a radio group or a segmented control; a select hides the choice behind a tap and saves nothing at four options. A select fits a known list that people can scan: states, categories, a few hundred time zones. A combobox fits when typing is faster than scrolling, the list comes from a server, or a free text answer is allowed.&lt;/p&gt;

&lt;p&gt;Numbers are the other trap. A native &lt;code&gt;type="number"&lt;/code&gt; field ignores &lt;code&gt;maxLength&lt;/code&gt; (&lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input#attributes" rel="noopener noreferrer"&gt;MDN lists the attribute&lt;/a&gt; only for text, search, url, tel, email and password inputs), accepts exponent input such as &lt;code&gt;1e5&lt;/code&gt;, and in some desktop browsers a scroll over the focused field changes its value. Our Input renders &lt;code&gt;type="number"&lt;/code&gt; as a text field with a decimal keypad, so &lt;code&gt;maxLength&lt;/code&gt; works and a scroll never changes the value.&lt;/p&gt;

&lt;h2&gt;
  
  
  What anatomy should every form field share?
&lt;/h2&gt;

&lt;p&gt;Every field should have the same three parts in the same order: a label above the control, the control's box, and one message row under it. The message row shows either the hint or the error, never both, and a counter at its end when the field has a limit. In accessible form fields, the parts people forget are the ones nobody sees: the ids, the live region and what a screen reader hears.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Label.&lt;/strong&gt; A real &lt;code&gt;&amp;lt;label&amp;gt;&lt;/code&gt; with &lt;code&gt;htmlFor&lt;/code&gt; pointing at the control's id, so clicking it focuses the field and screen readers name the control. The required mark after it is hidden from screen readers, because the control itself carries &lt;code&gt;required&lt;/code&gt;. When most fields are required, mark the few optional ones instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hint.&lt;/strong&gt; Helper text under the box, linked with &lt;code&gt;aria-describedby&lt;/code&gt;, so it is read after the label.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Error.&lt;/strong&gt; It replaces the hint. The control's &lt;code&gt;aria-describedby&lt;/code&gt; switches to the error's id, &lt;code&gt;aria-invalid&lt;/code&gt; turns on, and the box gets a red ring plus an icon and the text. &lt;a href="https://www.w3.org/WAI/WCAG22/Understanding/error-identification.html" rel="noopener noreferrer"&gt;WCAG 2.2 success criterion 3.3.1&lt;/a&gt; (Level A) requires that an automatically detected error is identified and "described to the user in text", so a red border alone fails.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Counter.&lt;/strong&gt; "12 / 70" at the end of the message row, amber from 90% and red at the limit. The visible counter is hidden from screen readers, which instead hear "7 characters left" once 10% or less remains, half a second after typing stops. Reading the count on every keystroke is noise.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Swapping the hint for the error, instead of stacking the error under it, means a field with a hint barely changes height when the error appears. The row opens and closes with a short collapse animation and the hint crossfades into the error, so the fields under it glide instead of jumping. The row stays mounted as a polite live region, which means a new error is announced once, without stealing focus.&lt;/p&gt;

&lt;p&gt;In Wingo UI the anatomy is the free &lt;a href="https://wingo-ui.com/components/field" rel="noopener noreferrer"&gt;Field&lt;/a&gt;. You can wrap any native control in it, and your own components can join it with one hook:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;use client&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ComponentProps&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;FIELD_INPUT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;FieldAddon&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;FieldBox&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;FieldControl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useFieldControl&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/components/ui/field&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// any native control: Field draws the label and messages, FieldControl wires the ids and aria onto the input&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;PriceField&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;error&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Field&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Price"&lt;/span&gt; &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"The final price, tax included."&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;FieldBox&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;FieldControl&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;input&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"price"&lt;/span&gt; &lt;span class="na"&gt;inputMode&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"decimal"&lt;/span&gt; &lt;span class="na"&gt;placeholder&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"0"&lt;/span&gt; &lt;span class="na"&gt;autoComplete&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"off"&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;FIELD_INPUT&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;FieldControl&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;FieldAddon&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;USD&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;FieldAddon&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;FieldBox&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;PlainInputProps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ComponentProps&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;input&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;description&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;error&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="c1"&gt;// your own control: one hook, and it works alone or inside a &amp;lt;Field&amp;gt;&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;PlainInput&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;description&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;required&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;disabled&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;readOnly&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="nx"&gt;PlainInputProps&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// the flags go through the hook: controlProps sets id, required, disabled and readOnly on the input&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;field&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useFieldControl&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;description&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;required&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;disabled&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;readOnly&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;field&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;wrap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;FieldBox&lt;/span&gt; &lt;span class="na"&gt;invalid&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;field&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;invalid&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;disabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;field&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;disabled&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;readOnly&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;field&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;readOnly&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;input&lt;/span&gt; &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;field&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;controlProps&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;FIELD_INPUT&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;FieldBox&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;,&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;FieldControl&lt;/code&gt; puts the Field's id, &lt;code&gt;aria-describedby&lt;/code&gt;, &lt;code&gt;aria-invalid&lt;/code&gt; and &lt;code&gt;aria-required&lt;/code&gt; on its child, and the child's own props win. &lt;code&gt;useFieldControl&lt;/code&gt; returns the same ids and flags for a custom control, plus &lt;code&gt;wrap()&lt;/code&gt;, which draws the label and message row around the control when it has its own label or error, and does nothing when a parent Field already does. Pass &lt;code&gt;required&lt;/code&gt;, &lt;code&gt;disabled&lt;/code&gt; and &lt;code&gt;readOnly&lt;/code&gt; through the hook, not straight onto the input: &lt;code&gt;controlProps&lt;/code&gt; always carries those keys, so spreading it after your own props would reset them to &lt;code&gt;undefined&lt;/code&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Live demo: Field.&lt;/strong&gt; The original article embeds an interactive demo here (Press Continue with a title under 10 characters: the error replaces the hint and the fields below glide down; type and it slides away again). &lt;a href="https://wingo-ui.com/blog/react-form-components-guide" rel="noopener noreferrer"&gt;Try it live on wingo-ui.com&lt;/a&gt; or open the &lt;a href="https://wingo-ui.com/components/field" rel="noopener noreferrer"&gt;Field docs&lt;/a&gt;. Install: &lt;code&gt;npx wingo-ui@latest add field&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The demo is a listing form with a 70 character title, a required price with a USD suffix and a city. The counter turns amber near the limit and the Continue button moves focus to the first field that needs fixing.&lt;/p&gt;

&lt;p&gt;Libraries differ in how much of this wiring they do for you. As of October 2026, the &lt;a href="https://ui.shadcn.com/docs/components/field" rel="noopener noreferrer"&gt;shadcn/ui Field&lt;/a&gt; is a set of layout parts: you connect &lt;code&gt;FieldLabel htmlFor&lt;/code&gt; to the input's id yourself, put &lt;code&gt;data-invalid&lt;/code&gt; on the Field and &lt;code&gt;aria-invalid&lt;/code&gt; on the control, and pass messages to &lt;code&gt;FieldError&lt;/code&gt;, which also accepts an &lt;code&gt;errors&lt;/code&gt; array from react-hook-form. &lt;a href="https://react-aria.adobe.com/useField" rel="noopener noreferrer"&gt;React Aria's useField&lt;/a&gt; goes the other way and "takes care of creating ids for each element and associating them with the correct ARIA attributes." Our Field sits closer to React Aria: the control reads the ids and flags from context. The explicit shadcn approach has one real advantage: there is no hidden context to debug, and if your team already lives in shadcn/ui it is a fine choice. We chose context because 31 of our controls take the same &lt;code&gt;error&lt;/code&gt; prop and have to produce the same markup every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why should every control take label, description and error props?
&lt;/h2&gt;

&lt;p&gt;Because a form then reads as a flat list of fields, and swapping one control for another never changes the wiring. In Wingo UI, 31 controls as of release 1.7.0 (Input, Textarea, Select, Combobox, Phone Input, the date pickers, Checkbox, Switch, Radio Group, Slider and the rest) take &lt;code&gt;label&lt;/code&gt;, &lt;code&gt;description&lt;/code&gt; and &lt;code&gt;error&lt;/code&gt; directly, most of them also &lt;code&gt;required&lt;/code&gt;, &lt;code&gt;invalid&lt;/code&gt;, &lt;code&gt;disabled&lt;/code&gt; and &lt;code&gt;readOnly&lt;/code&gt;, and render the Field anatomy themselves:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;use client&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;DatePicker&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/components/ui/date-picker&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Input&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/components/ui/input&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;PhoneInput&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/components/ui/phone-input&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Select&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/components/ui/select&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;STATES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;CA&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;California&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;NY&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;New York&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;TX&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Texas&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;SameAnatomy&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setTitle&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"flex flex-col gap-4"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Input&lt;/span&gt;
        &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Listing title"&lt;/span&gt;
        &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Brand, model and condition."&lt;/span&gt;
        &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;onValueChange&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;setTitle&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;The title needs at least 10 characters.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;showCount&lt;/span&gt;
        &lt;span class="na"&gt;maxLength&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;70&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;required&lt;/span&gt;
      &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Select&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"State"&lt;/span&gt; &lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;STATES&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"We ship to these states only."&lt;/span&gt; &lt;span class="na"&gt;required&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;PhoneInput&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Phone"&lt;/span&gt; &lt;span class="na"&gt;defaultCountry&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"US"&lt;/span&gt; &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Only for delivery updates."&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;DatePicker&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Delivery date"&lt;/span&gt; &lt;span class="na"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"en-US"&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"We do not deliver on Sundays."&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four different controls, one shape. Anything the Field takes beyond those props (an optional mark, a label action such as "Forgot your password?", a horizontal layout with a label column) goes through &lt;code&gt;fieldProps&lt;/code&gt;, which Input, Select, Phone Input, Date Picker and most other controls accept. Sections of a form go in a &lt;code&gt;FieldGroup&lt;/code&gt;, which is a real &lt;code&gt;&amp;lt;fieldset&amp;gt;&lt;/code&gt; with a &lt;code&gt;&amp;lt;legend&amp;gt;&lt;/code&gt;, so a screen reader announces "Shipping address" before the first field of the group. A disabled FieldGroup disables every field inside it, and its &lt;code&gt;columns&lt;/code&gt; prop gives one column on phones and two from 640px.&lt;/p&gt;

&lt;p&gt;Two details in this anatomy save real bugs. A click on a Select's label focuses the trigger without clicking it, so the word "State" never pops a list open. And the &lt;code&gt;name&lt;/code&gt; prop on each of these four controls submits a value through a native or hidden input, which matters more than it sounds; there is a section on it below.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you wire react-hook-form and zod to these components?
&lt;/h2&gt;

&lt;p&gt;Use &lt;code&gt;register&lt;/code&gt; for controls that render a native input and pass the native &lt;code&gt;onChange&lt;/code&gt;, &lt;code&gt;onBlur&lt;/code&gt; and &lt;code&gt;ref&lt;/code&gt; through, and &lt;code&gt;Controller&lt;/code&gt; for everything else. Validate with one zod schema whose rules share one message set, and show errors once a field has been left. There is no special set of React Hook Form components to install: if a component forwards those three things to its focusable element, RHF can drive it.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://wingo-ui.com/components/form-validation" rel="noopener noreferrer"&gt;form-validation&lt;/a&gt; lib packages the form glue our blocks share: &lt;code&gt;createValidators()&lt;/code&gt; returns zod rules with one set of messages (&lt;code&gt;text&lt;/code&gt;, &lt;code&gt;name&lt;/code&gt;, &lt;code&gt;email&lt;/code&gt;, &lt;code&gt;password&lt;/code&gt;, &lt;code&gt;phone&lt;/code&gt;, &lt;code&gt;url&lt;/code&gt;, &lt;code&gt;otp&lt;/code&gt;, &lt;code&gt;accept&lt;/code&gt; for a ticked checkbox, &lt;code&gt;number&lt;/code&gt;, plus Romanian &lt;code&gt;cui&lt;/code&gt;, &lt;code&gt;iban&lt;/code&gt; and &lt;code&gt;postalCode&lt;/code&gt;), &lt;code&gt;useZodForm(schema)&lt;/code&gt; is &lt;code&gt;useForm&lt;/code&gt; with the zod resolver and our timing, and &lt;code&gt;fieldError(form, "email")&lt;/code&gt; turns a field's error into the string a component's &lt;code&gt;error&lt;/code&gt; prop takes. Here is a delivery request form with seven fields:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;use client&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Controller&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;react-hook-form&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;zod&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;addDays&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;startOfToday&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;date-fns&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Button&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/components/ui/button&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Checkbox&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/components/ui/checkbox&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;DatePicker&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/components/ui/date-picker&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Input&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/components/ui/input&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;PhoneInput&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/components/ui/phone-input&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Select&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;SelectOption&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/components/ui/select&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Textarea&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/components/ui/textarea&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createValidators&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;fieldError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useZodForm&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/lib/form-validation&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createValidators&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;STATES&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SelectOption&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;CA&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;California&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;keywords&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;CA&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;NY&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;New York&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;keywords&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;NY&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;TX&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Texas&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;keywords&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;TX&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;WA&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Washington&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;keywords&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;WA&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;name&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;email&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;phone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;phone&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;country&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;US&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;deliveryDate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;date&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;required&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="na"&gt;notes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;max&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="na"&gt;terms&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;accept&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;DeliveryRequest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;output&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;schema&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;DeliveryForm&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;onSave&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;onSave&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;values&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DeliveryRequest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;form&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useZodForm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;defaultValues&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;phone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;notes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;terms&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;form&lt;/span&gt; &lt;span class="na"&gt;onSubmit&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;handleSubmit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;onSave&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;noValidate&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"flex flex-col gap-4"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* native inputs inside: register is enough */&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Input&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Full name"&lt;/span&gt; &lt;span class="na"&gt;autoComplete&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"name"&lt;/span&gt; &lt;span class="na"&gt;required&lt;/span&gt; &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;register&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;name&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;fieldError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;name&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Input&lt;/span&gt;
        &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Email"&lt;/span&gt;
        &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"email"&lt;/span&gt;
        &lt;span class="na"&gt;autoComplete&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"email"&lt;/span&gt;
        &lt;span class="na"&gt;required&lt;/span&gt;
        &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;register&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;email&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;fieldError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;email&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;

      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* everything else: Controller, with the ref so a failed submit can focus it */&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Controller&lt;/span&gt;
        &lt;span class="na"&gt;control&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;control&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"phone"&lt;/span&gt;
        &lt;span class="na"&gt;render&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;field&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;PhoneInput&lt;/span&gt;
            &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Phone"&lt;/span&gt;
            &lt;span class="na"&gt;defaultCountry&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"US"&lt;/span&gt;
            &lt;span class="na"&gt;preferredCountries&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;US&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;CA&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
            &lt;span class="na"&gt;required&lt;/span&gt;
            &lt;span class="na"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;field&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ref&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
            &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;field&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
            &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;field&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
            &lt;span class="na"&gt;onValueChange&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e164&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;field&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onChange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e164&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
            &lt;span class="na"&gt;onBlur&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;field&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onBlur&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
            &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;fieldError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;phone&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
          &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Controller&lt;/span&gt;
        &lt;span class="na"&gt;control&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;control&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"state"&lt;/span&gt;
        &lt;span class="na"&gt;render&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;field&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Select&lt;/span&gt;
            &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"State"&lt;/span&gt;
            &lt;span class="na"&gt;placeholder&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Choose the state"&lt;/span&gt;
            &lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;STATES&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
            &lt;span class="na"&gt;required&lt;/span&gt;
            &lt;span class="na"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;field&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ref&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
            &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;field&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
            &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;field&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
            &lt;span class="na"&gt;onValueChange&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;field&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onChange&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
            &lt;span class="na"&gt;onBlur&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;field&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onBlur&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
            &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;fieldError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;state&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
          &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Controller&lt;/span&gt;
        &lt;span class="na"&gt;control&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;control&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"deliveryDate"&lt;/span&gt;
        &lt;span class="na"&gt;render&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;field&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;DatePicker&lt;/span&gt;
            &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Delivery date"&lt;/span&gt;
            &lt;span class="na"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"en-US"&lt;/span&gt;
            &lt;span class="na"&gt;min&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;addDays&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;startOfToday&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
            &lt;span class="na"&gt;disabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;dayOfWeek&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
            &lt;span class="na"&gt;required&lt;/span&gt;
            &lt;span class="na"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;field&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ref&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
            &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;field&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
            &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;field&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
            &lt;span class="na"&gt;onValueChange&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;field&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onChange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;date&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
            &lt;span class="na"&gt;onBlur&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;field&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onBlur&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
            &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;fieldError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;deliveryDate&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
          &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Controller&lt;/span&gt;
        &lt;span class="na"&gt;control&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;control&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"notes"&lt;/span&gt;
        &lt;span class="na"&gt;render&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;field&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Textarea&lt;/span&gt;
            &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Notes for the driver"&lt;/span&gt;
            &lt;span class="na"&gt;maxLength&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
            &lt;span class="na"&gt;showCount&lt;/span&gt;
            &lt;span class="na"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;field&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ref&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
            &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;field&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
            &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;field&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
            &lt;span class="na"&gt;onValueChange&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;field&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onChange&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
            &lt;span class="na"&gt;onBlur&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;field&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onBlur&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
            &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;fieldError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;notes&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
          &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Controller&lt;/span&gt;
        &lt;span class="na"&gt;control&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;control&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"terms"&lt;/span&gt;
        &lt;span class="na"&gt;render&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;field&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Checkbox&lt;/span&gt;
            &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"I accept the delivery terms"&lt;/span&gt;
            &lt;span class="na"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;field&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ref&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
            &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;field&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
            &lt;span class="na"&gt;checked&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;field&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
            &lt;span class="na"&gt;onCheckedChange&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;checked&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;field&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onChange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;checked&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
            &lt;span class="na"&gt;onBlur&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;field&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onBlur&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
            &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;fieldError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;terms&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
          &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"submit"&lt;/span&gt; &lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"lg"&lt;/span&gt; &lt;span class="na"&gt;fullWidth&lt;/span&gt; &lt;span class="na"&gt;loading&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;formState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isSubmitting&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        Request delivery
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;form&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Install the pieces with one command; the CLI brings react-hook-form, &lt;code&gt;@hookform/resolvers&lt;/code&gt;, zod, libphonenumber-js and date-fns along:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx wingo-ui@latest add form-validation input &lt;span class="k"&gt;select &lt;/span&gt;phone-input date-picker textarea checkbox
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The phone input, the date picker and the form-validation lib are Pro: run &lt;code&gt;npx wingo-ui@latest login&lt;/code&gt; once with a Pro account, or the CLI stops and tells you to sign in. The code uses zod 4 and react-hook-form 7. A few lines in it carry more weight than they look.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why validate once a field is left?
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;useZodForm&lt;/code&gt; sets &lt;code&gt;mode: "onTouched"&lt;/code&gt; and &lt;code&gt;reValidateMode: "onChange"&lt;/code&gt;. The &lt;a href="https://react-hook-form.com/docs/useform" rel="noopener noreferrer"&gt;React Hook Form docs&lt;/a&gt; describe onTouched as validation that "is initially triggered on the first blur event. After that, it is triggered on every change event." So nobody hears that their email is invalid after typing one letter, and once an error shows, it clears on the keystroke that fixes it. The same docs warn that &lt;code&gt;mode: "onChange"&lt;/code&gt; "often comes with a significant impact on performance", which is a second reason to avoid it on long forms.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why pass field.ref to every Controller?
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;shouldFocusError&lt;/code&gt; is on by default, so a failed submit moves focus to the first field with an error, but only if RHF holds a ref to something focusable. The &lt;a href="https://react-hook-form.com/docs/usecontroller/controller" rel="noopener noreferrer"&gt;Controller docs&lt;/a&gt; say to assign &lt;code&gt;field.ref&lt;/code&gt; "to allow hook form to focus the error input". In these components &lt;code&gt;ref&lt;/code&gt; is a plain prop (React 19, no &lt;code&gt;forwardRef&lt;/code&gt;) and it lands on the element a person would focus: the Select's trigger button, the Phone Input's number input, the Date Picker's text input, the Checkbox's button. Our &lt;a href="https://wingo-ui.com/blog/react-19-forwardref-ref-as-prop" rel="noopener noreferrer"&gt;React 19 forwardRef migration guide&lt;/a&gt; shows how to move your own fields to that pattern.&lt;/p&gt;

&lt;h3&gt;
  
  
  When is register not enough?
&lt;/h3&gt;

&lt;p&gt;When the value is anything other than the string in a native input: an E.164 phone number, a &lt;code&gt;Date&lt;/code&gt;, a boolean, a value picked from a list. Also when a text field shows a counter. Input and Textarea keep their length in their own state. They pick up a native form reset, but a value that RHF writes straight into the DOM with &lt;code&gt;setValue()&lt;/code&gt; or &lt;code&gt;reset(values)&lt;/code&gt; reaches the counter only when the field gets focus again. The notes field above goes through &lt;code&gt;Controller&lt;/code&gt; for that reason, even though a native textarea sits inside it.&lt;/p&gt;

&lt;h3&gt;
  
  
  What about the date and the phone number?
&lt;/h3&gt;

&lt;p&gt;The date picker reports &lt;code&gt;null&lt;/code&gt; when cleared, so the form stores &lt;code&gt;undefined&lt;/code&gt; instead and &lt;code&gt;z.date({ error })&lt;/code&gt; fails with the required message. A typed date reaches the form only once it is complete and allowed, so a typed Sunday never does. The phone input reports an E.164 string such as &lt;code&gt;+12015550123&lt;/code&gt; once the number is possible and an empty string before that. &lt;code&gt;v.phone({ country: "US" })&lt;/code&gt; checks it with libphonenumber-js. The country only matters for values without a calling code, such as a number typed into a plain Input, and it defaults to &lt;code&gt;RO&lt;/code&gt;, so set it on a US form.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;noValidate&lt;/code&gt; on the form turns off the browser's own bubbles, so the zod messages are the only ones people see. Without it, an empty field with &lt;code&gt;required&lt;/code&gt; makes the browser block the submit with its own tooltip, and &lt;code&gt;handleSubmit&lt;/code&gt; never runs to show your messages.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Live demo: Form validation.&lt;/strong&gt; The original article embeds an interactive demo here (Submit the empty form: every error shows at once and focus jumps to the first one; fix a field and its error clears as you type). &lt;a href="https://wingo-ui.com/blog/react-form-components-guide" rel="noopener noreferrer"&gt;Try it live on wingo-ui.com&lt;/a&gt; or open the &lt;a href="https://wingo-ui.com/components/form-validation" rel="noopener noreferrer"&gt;Form validation docs&lt;/a&gt;. Install: &lt;code&gt;npx wingo-ui@latest add form-validation&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The demo is a sign-up form built on the same lib: name, email, phone, an optional company tax ID, a password with a strength rule and a terms checkbox.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should the server validate the same schema?
&lt;/h3&gt;

&lt;p&gt;Yes, always: the client schema is a convenience for people, and anything that reaches your server action or API route has to be checked again. The Next.js &lt;a href="https://nextjs.org/docs/app/guides/forms" rel="noopener noreferrer"&gt;forms guide&lt;/a&gt; suggests parsing the submitted values with a schema library such as zod inside the Server Action. One catch with our lib today: &lt;code&gt;lib/form-validation.ts&lt;/code&gt; starts with &lt;code&gt;"use client"&lt;/code&gt; because it holds the &lt;code&gt;useZodForm&lt;/code&gt; hook, so server code cannot call &lt;code&gt;createValidators()&lt;/code&gt; from it. Write the server schema with zod directly, or, since the file is your source after install, move the rules into a module without the directive.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do you need react-hook-form at all?
&lt;/h2&gt;

&lt;p&gt;Not for a short form. If every control submits through a real input with a &lt;code&gt;name&lt;/code&gt;, the browser's &lt;code&gt;FormData&lt;/code&gt; already holds clean values, required fields block the submit natively, a form reset restores the defaults and autofill works. That is why the controls in this guide render something native when you pass &lt;code&gt;name&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Select&lt;/strong&gt; renders a hidden native &lt;code&gt;&amp;lt;select&amp;gt;&lt;/code&gt;, so &lt;code&gt;FormData&lt;/code&gt;, &lt;code&gt;required&lt;/code&gt;, form reset and browser autofill work. &lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Attributes/autocomplete" rel="noopener noreferrer"&gt;MDN notes&lt;/a&gt; that browsers may require a &lt;code&gt;name&lt;/code&gt; or &lt;code&gt;id&lt;/code&gt; and an owning form before they offer autofill, which is exactly what a select built from divs lacks. Pass &lt;code&gt;autoComplete="address-level1"&lt;/code&gt; (the state, in a US address) and the browser can fill it from a saved address.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Phone Input&lt;/strong&gt; renders a hidden input with the E.164 number, while the visible input shows the formatted national digits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Date Picker&lt;/strong&gt; renders a hidden input with the local ISO date (&lt;code&gt;2026-10-23&lt;/code&gt;, or &lt;code&gt;2026-10-23T14:30&lt;/code&gt; with a time), never shifted to UTC, which avoids the off by one day bug that &lt;code&gt;toISOString()&lt;/code&gt; causes east of UTC.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Checkbox&lt;/strong&gt; submits its &lt;code&gt;value&lt;/code&gt; through Radix's hidden input while checked; &lt;strong&gt;Input&lt;/strong&gt; and &lt;strong&gt;Textarea&lt;/strong&gt; are native elements.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;use client&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;FormEvent&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Button&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/components/ui/button&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;DatePicker&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/components/ui/date-picker&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;PhoneInput&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/components/ui/phone-input&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Select&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/components/ui/select&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;STATES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;CA&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;California&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;NY&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;New York&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;TX&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Texas&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;QuoteForm&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;onQuote&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;onQuote&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;values&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;FormDataEntryValue&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;submit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;FormEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;HTMLFormElement&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preventDefault&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="c1"&gt;// { phone: "+12015550123", state: "NY", pickup: "2026-10-23" }&lt;/span&gt;
    &lt;span class="nf"&gt;onQuote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromEntries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;FormData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentTarget&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;form&lt;/span&gt; &lt;span class="na"&gt;onSubmit&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;submit&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"flex flex-col gap-4"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;PhoneInput&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"phone"&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Phone"&lt;/span&gt; &lt;span class="na"&gt;defaultCountry&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"US"&lt;/span&gt; &lt;span class="na"&gt;required&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Select&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"state"&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"State"&lt;/span&gt; &lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;STATES&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;autoComplete&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"address-level1"&lt;/span&gt; &lt;span class="na"&gt;required&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;DatePicker&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"pickup"&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Pickup date"&lt;/span&gt; &lt;span class="na"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"en-US"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"submit"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Get a quote&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;form&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same hidden inputs are what a Server Action receives when you pass it to &lt;code&gt;&amp;lt;form action&amp;gt;&lt;/code&gt;, because the action is called with the form's &lt;code&gt;FormData&lt;/code&gt;. Our rule of thumb: plain &lt;code&gt;FormData&lt;/code&gt; for forms of up to five or six fields with simple rules; react-hook-form once you need cross-field rules (a password and its confirmation, which &lt;code&gt;withMatch&lt;/code&gt; in the lib handles), errors that update while typing, dirty tracking for a "You have unsaved changes" prompt, or lists of fields people add and remove.&lt;/p&gt;

&lt;h2&gt;
  
  
  How should each control behave on a phone?
&lt;/h2&gt;

&lt;p&gt;Fields you type into get 16px text, the right keypad and the right Enter key. Fields you choose from open a bottom sheet instead of a popover. Nothing you tap has a hit area under 44px. The broader rulebook is in our guide to &lt;a href="https://wingo-ui.com/blog/mobile-first-react-components" rel="noopener noreferrer"&gt;mobile-first React components&lt;/a&gt;; here is how it applies control by control.&lt;/p&gt;

&lt;h3&gt;
  
  
  Text fields: 16px, the keypad and the Enter key
&lt;/h3&gt;

&lt;p&gt;Safari on iOS zooms the page when you focus a field whose text is smaller than 16px, and it does not zoom back out on blur (&lt;a href="https://weblog.west-wind.com/posts/2023/Apr/17/Preventing-iOS-Textbox-Auto-Zooming-and-ViewPort-Sizing" rel="noopener noreferrer"&gt;Rick Strahl's write-up&lt;/a&gt; has the details). The fix is the font size. Avoid &lt;code&gt;maximum-scale=1&lt;/code&gt; in the viewport meta tag: the same write-up notes that Android honors it and blocks pinch zoom, which hurts the people who rely on zooming. Every Wingo UI field box starts at &lt;code&gt;text-base&lt;/code&gt; (16px) and switches to its desktop size from 768px up: &lt;code&gt;text-base md:text-sm&lt;/code&gt; gives the md box 14px there.&lt;/p&gt;

&lt;p&gt;The keypad follows &lt;code&gt;type&lt;/code&gt; and &lt;code&gt;inputMode&lt;/code&gt;: &lt;code&gt;email&lt;/code&gt; adds the @ key, &lt;code&gt;tel&lt;/code&gt; shows the phone pad, &lt;code&gt;decimal&lt;/code&gt; shows digits and a separator. &lt;code&gt;autoComplete&lt;/code&gt; with the right token (&lt;code&gt;name&lt;/code&gt;, &lt;code&gt;email&lt;/code&gt;, &lt;code&gt;tel-national&lt;/code&gt;, &lt;code&gt;new-password&lt;/code&gt;, &lt;code&gt;postal-code&lt;/code&gt;) lets the browser fill the field, and &lt;code&gt;enterKeyHint="next"&lt;/code&gt; on every field but the last (&lt;code&gt;"done"&lt;/code&gt; there) labels the Enter key with what it will do. The md box is 40px with a mouse and 44px on touch screens through Tailwind's &lt;code&gt;pointer-coarse&lt;/code&gt; variant, and the clear button keeps a 44px hit area even though it looks small.&lt;/p&gt;

&lt;h3&gt;
  
  
  Select and combobox: a sheet, and a keyboard that waits
&lt;/h3&gt;

&lt;p&gt;Below 768px the Select opens a bottom sheet titled with the field's label, with 48px rows and the chosen row scrolled into view. A tap picks and closes. Lists of more than eight options get a search row at the top, but on a touch screen it waits for a tap before it takes focus, so the keyboard does not cover half the list you were about to scroll. The Combobox does the opposite on purpose: a tap opens a full-height sheet with the keyboard already up, because typing is the whole point of a combobox. Pass &lt;code&gt;responsive={false}&lt;/code&gt; to keep the popover on phones, for example inside a sheet that is already full screen.&lt;/p&gt;

&lt;h3&gt;
  
  
  Dates and times: no keyboard
&lt;/h3&gt;

&lt;p&gt;On a desktop, typing a date is faster than clicking one. The Date Picker reads "09232026" as 09/23/2026, "+3" as three days from today and "tomorrow" as tomorrow, and it formats the text the way the locale writes it. On a phone the field never raises the keyboard. A tap opens a sheet with one month of 44px days you can swipe between, plus a row of chips when you turn on &lt;code&gt;presets&lt;/code&gt; (Today, Tomorrow, Next week, or your own). With &lt;code&gt;withTime&lt;/code&gt;, the time field in the sheet opens its own wheel sheet. The Time Picker uses the same wheels, which snap like the iOS picker and give a short vibration tick per row where the browser supports the Vibration API.&lt;/p&gt;

&lt;h3&gt;
  
  
  Phone numbers: one clean value
&lt;/h3&gt;

&lt;p&gt;The Phone Input raises the phone keypad, formats the number as the country writes it and keeps the caret after the digit you typed. Pasting or autofilling a number that starts with &lt;code&gt;+44&lt;/code&gt; or &lt;code&gt;0044&lt;/code&gt; switches the country by itself. The flag button opens the country list, which is a bottom sheet with search on phones, and you can search by country name, ISO code or calling code. Whatever the person types, your form receives one E.164 string.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Live demo: Phone Input.&lt;/strong&gt; The original article embeds an interactive demo here (Type a US number, then type or paste one that starts with +44: the flag switches to the United Kingdom, and the readout shows the clean value the form submits). &lt;a href="https://wingo-ui.com/blog/react-form-components-guide" rel="noopener noreferrer"&gt;Try it live on wingo-ui.com&lt;/a&gt; or open the &lt;a href="https://wingo-ui.com/components/phone-input" rel="noopener noreferrer"&gt;Phone Input docs&lt;/a&gt;. Install: &lt;code&gt;npx wingo-ui@latest add phone-input&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  One-time codes: autofill first
&lt;/h3&gt;

&lt;p&gt;The &lt;a href="https://wingo-ui.com/components/otp-input" rel="noopener noreferrer"&gt;OTP Input&lt;/a&gt; puts &lt;code&gt;autocomplete="one-time-code"&lt;/code&gt; on its first slot, which &lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Attributes/autocomplete" rel="noopener noreferrer"&gt;MDN describes&lt;/a&gt; as the token for "a one-time password (OTP) for verifying user identity", so the browser can offer a code it received by text message. Slots raise the numeric keypad, a pasted code fills every slot at once, and Backspace clears a slot before it steps back.&lt;/p&gt;

&lt;p&gt;It is also the one control in this guide that does not join the Field anatomy: it renders a row of single-character inputs, each named "Digit 1 of 6" and so on, and its error state is a red ring and a shake. Give the row a group name and put the error in text under it with &lt;code&gt;FieldMessage&lt;/code&gt;, which works outside a Field:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;use client&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;FieldMessage&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/components/ui/field&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;OtpInput&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;OtpStatus&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/components/ui/otp-input&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;VerifyPhone&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;phone&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;verify&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;phone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setStatus&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;OtpStatus&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;idle&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"flex flex-col gap-3"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"otp-title"&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"text-sm text-muted-foreground"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        Enter the 6 digit code we sent to &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;phone&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;.
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;OtpInput&lt;/span&gt;
        &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"group"&lt;/span&gt;
        &lt;span class="na"&gt;aria-labelledby&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"otp-title"&lt;/span&gt;
        &lt;span class="na"&gt;length&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;autoFocus&lt;/span&gt;
        &lt;span class="na"&gt;onChange&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;idle&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;onComplete&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setStatus&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;success&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;FieldMessage&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;That code is not right. Check the latest text message.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We skip react-hook-form here. A code screen has one value and one rule, and the answer comes from the server, so &lt;code&gt;onComplete&lt;/code&gt; and a status are all the state it needs. When you do need it, our &lt;a href="https://wingo-ui.com/blog/react-otp-input" rel="noopener noreferrer"&gt;React OTP input guide&lt;/a&gt; wires the code into react-hook-form and zod, explains why SMS autofill fails silently and adds a resend timer.&lt;/p&gt;

&lt;h3&gt;
  
  
  Numbers and money
&lt;/h3&gt;

&lt;p&gt;The Number Input and the Currency Input read numbers the way the locale writes them: 1,234.5 in English, 1.234,5 in Romanian. The Number Input raises the numeric keypad, or the decimal one when &lt;code&gt;step&lt;/code&gt; or &lt;code&gt;decimals&lt;/code&gt; allows decimals, refuses keystrokes that can never become a valid number (a minus when &lt;code&gt;min&lt;/code&gt; is 0 or more), and has steppers with a 44px hit area on touch. The Currency Input pads the cents on blur ("1.234,5" becomes "1.234,50") and shows quick amounts as chips through &lt;code&gt;presets&lt;/code&gt;, which beat typing for tips and donations.&lt;/p&gt;

&lt;p&gt;Test negative numbers on a real phone: &lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/inputmode" rel="noopener noreferrer"&gt;MDN notes&lt;/a&gt; that with the &lt;code&gt;numeric&lt;/code&gt; and &lt;code&gt;decimal&lt;/code&gt; input modes, "devices may or may not show a minus key". If yours does not, pass &lt;code&gt;inputMode="text"&lt;/code&gt; to the Number Input or rely on the steppers.&lt;/p&gt;

&lt;h2&gt;
  
  
  When should a form show its errors?
&lt;/h2&gt;

&lt;p&gt;Show an error after the person leaves the field, update it live while they fix it, and on submit show every error at once and move focus to the first. That is the &lt;code&gt;onTouched&lt;/code&gt; timing from above, and it fits almost every form. A few rules around it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Never on load.&lt;/strong&gt; An empty required field is not an error until someone has had a chance to fill it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the submit button enabled.&lt;/strong&gt; A disabled button gives no reason, and Tab skips it, so keyboard users cannot even reach it. Let the submit fail and show the errors.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check slow things without blocking.&lt;/strong&gt; An Input with &lt;code&gt;loading&lt;/code&gt; shows a spinner at the end of the box (in place of the trailing icon, if it has one), sets &lt;code&gt;aria-busy&lt;/code&gt; and stays editable, so a username check runs while the person keeps typing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write errors as instructions.&lt;/strong&gt; "Enter a valid email address" tells people what to do; "Invalid input" makes them guess. Most of the lib's English messages follow that pattern, and &lt;code&gt;createValidators(VALIDATION_MESSAGES_RO)&lt;/code&gt; swaps in the Romanian set in one line.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What should a React form components library give you?
&lt;/h2&gt;

&lt;p&gt;A React form components library earns its place when it removes the wiring you would otherwise repeat in every field, and lets you change what it got wrong. Our checklist, which you can apply to any library, ours included (for prices and licenses, see our &lt;a href="https://wingo-ui.com/blog/shadcn-alternatives" rel="noopener noreferrer"&gt;shadcn/ui alternatives comparison&lt;/a&gt;):&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;One anatomy for every control&lt;/strong&gt;, with the label, hint, error and counter ids wired for you, and a hook to bring your own controls into it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Native form participation.&lt;/strong&gt; A &lt;code&gt;name&lt;/code&gt; that ends up in &lt;code&gt;FormData&lt;/code&gt;, &lt;code&gt;required&lt;/code&gt; that blocks the submit, a form reset that restores defaults, and autofill that reaches custom selects.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Controlled and uncontrolled modes&lt;/strong&gt;, and a &lt;code&gt;ref&lt;/code&gt; that lands on the element a person would focus, so a form library can focus the first error.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Phone behavior built into the same component&lt;/strong&gt;: 16px text, keypads, 44px targets, bottom sheets below 768px. A separate mobile component doubles every form.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Source you own.&lt;/strong&gt; Wingo UI copies the files into your project, like shadcn/ui, so you can change a label, a timing or a schema rule without waiting for a release, and &lt;code&gt;npx wingo-ui@latest update&lt;/code&gt; merges our later fixes into the files you edited. The same 3-way merge done by hand with git, for files from the shadcn CLI, is in our guide to &lt;a href="https://wingo-ui.com/blog/update-shadcn-components-without-losing-edits" rel="noopener noreferrer"&gt;updating shadcn components without losing edits&lt;/a&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Field, Input, Textarea, Select, Checkbox, Switch, Radio Group, Segmented Control and the OTP input are free. The phone input, the date and time pickers, Combobox, Multi-select, the number and currency inputs and the form-validation lib are part of Pro, which costs $8 a month, $80 a year or $150 once as of October 2026 (see &lt;a href="https://wingo-ui.com/pricing" rel="noopener noreferrer"&gt;Wingo UI Pro pricing&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Which component guides go deeper?
&lt;/h2&gt;

&lt;p&gt;These posts in the &lt;a href="https://wingo-ui.com/blog/category/components" rel="noopener noreferrer"&gt;component guides category&lt;/a&gt; take one field, one rule or one component further:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://wingo-ui.com/blog/react-otp-input" rel="noopener noreferrer"&gt;React OTP input&lt;/a&gt;: paste, SMS autofill, react-hook-form with zod and a resend timer.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://wingo-ui.com/blog/romanian-cui-validation-javascript" rel="noopener noreferrer"&gt;Romanian CUI validation&lt;/a&gt;: the company tax ID check digit, a zod rule and a lookup that proves the company exists.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://wingo-ui.com/blog/iban-validation-javascript" rel="noopener noreferrer"&gt;IBAN validation in JavaScript&lt;/a&gt;: the mod 97 check, why a regex is not enough and a field that formats as you type.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://wingo-ui.com/blog/romanian-cnp-validator-javascript" rel="noopener noreferrer"&gt;Romanian CNP validator&lt;/a&gt;: decoding and validating a personal numeric code, and whether to store it at all.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://wingo-ui.com/blog/tanstack-table-v9-data-table" rel="noopener noreferrer"&gt;TanStack Table v9 data table&lt;/a&gt;: filters, bulk actions, pinning and server pagination, with cards on phones.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://wingo-ui.com/blog/react-command-palette" rel="noopener noreferrer"&gt;React command palette&lt;/a&gt;: Cmd+K with nested pages, async results and a full-height sheet on phones.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://wingo-ui.com/blog/dnd-kit-mobile-touch-drag" rel="noopener noreferrer"&gt;dnd kit on mobile&lt;/a&gt;: touch drag that still lets the page scroll, and a tap path for people who cannot drag.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://wingo-ui.com/blog/update-shadcn-components-without-losing-edits" rel="noopener noreferrer"&gt;Updating shadcn components&lt;/a&gt;: pulling upstream fixes into files you already edited.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where should you start?
&lt;/h2&gt;

&lt;p&gt;Install the free anatomy and the controls most forms need, then build one real form with them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx wingo-ui@latest add field input textarea &lt;span class="k"&gt;select &lt;/span&gt;checkbox
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The command copies the source files into your project, installs the npm packages they import and records the versions, so later updates can merge into your edits. In a project without a &lt;code&gt;wingo-ui.json&lt;/code&gt;, run &lt;code&gt;npx wingo-ui@latest init&lt;/code&gt; first. Open the &lt;a href="https://wingo-ui.com/components/field" rel="noopener noreferrer"&gt;Field docs&lt;/a&gt; for the playground and every prop, and add the phone, date and validation pieces when your forms need them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Components in this post
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://wingo-ui.com/components/field" rel="noopener noreferrer"&gt;Field&lt;/a&gt; (Free): The wrapper every form control shares: label, hint, animated error and counter, fieldsets, and the one box recipe all inputs use. Install: &lt;code&gt;npx wingo-ui@latest add field&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://wingo-ui.com/components/input" rel="noopener noreferrer"&gt;Input&lt;/a&gt; (Free): The single-line text field: icons and addons inside the box, a clear button, a loading spinner, a counter and floating labels. Install: &lt;code&gt;npx wingo-ui@latest add input&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://wingo-ui.com/components/select" rel="noopener noreferrer"&gt;Select&lt;/a&gt; (Free): A custom select with search, groups, icons, thousands of virtualized rows and a hidden native select for forms. Install: &lt;code&gt;npx wingo-ui@latest add select&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://wingo-ui.com/components/otp-input" rel="noopener noreferrer"&gt;OTP Input&lt;/a&gt; (Free): A one-time-code input whose characters roll into place behind a caret that slides from slot to slot. Install: &lt;code&gt;npx wingo-ui@latest add otp-input&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://wingo-ui.com/components/phone-input" rel="noopener noreferrer"&gt;Phone Input&lt;/a&gt; (Pro): A phone number field with a searchable country picker, formatting as you type, validation and one clean E.164 value out. Install: &lt;code&gt;npx wingo-ui@latest add phone-input&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://wingo-ui.com/components/date-picker" rel="noopener noreferrer"&gt;Date Picker&lt;/a&gt; (Pro): A date field to type in your locale or pick from a calendar popover, with presets, limits and an optional time; a sheet on phones. Install: &lt;code&gt;npx wingo-ui@latest add date-picker&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://wingo-ui.com/components/form-validation" rel="noopener noreferrer"&gt;Form validation&lt;/a&gt; (Pro): Zod rules with one set of messages (English and Romanian), react-hook-form glue and a pretend request, for the forms of blocks. Install: &lt;code&gt;npx wingo-ui@latest add form-validation&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://wingo-ui.com/components/textarea" rel="noopener noreferrer"&gt;Textarea&lt;/a&gt; (Free): Multi-line text that grows with what you write, with a counter, an optional resize handle, a toolbar slot and mod+enter to submit. Install: &lt;code&gt;npx wingo-ui@latest add textarea&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://wingo-ui.com/components/checkbox" rel="noopener noreferrer"&gt;Checkbox&lt;/a&gt; (Free): A box that is on, off or mixed with a check that draws itself, a label row that is the whole target, cards, and groups with select all. Install: &lt;code&gt;npx wingo-ui@latest add checkbox&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Do I need react-hook-form to build forms in React?
&lt;/h3&gt;

&lt;p&gt;No. A short form works with native inputs, name attributes and FormData, and Wingo UI's Input, Textarea, Select, Checkbox, Phone Input and Date Picker all submit their value through a native or hidden input once they have a name. React Hook Form pays off once you need per-field validation timing, cross-field rules, dirty state or field arrays.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I use react-hook-form with custom components?
&lt;/h3&gt;

&lt;p&gt;Use register when the component renders a native input and passes onChange, onBlur and ref through to it, like a text input. Use Controller for anything whose value is not a string in a native input (a select, a date, a phone number, a checkbox), and pass field.ref so a failed submit can focus it.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I make form fields accessible in React?
&lt;/h3&gt;

&lt;p&gt;Tie every label to its control with htmlFor and id, point aria-describedby at the hint or, while it shows, the error, and set aria-invalid on the invalid control. Describe each error in text instead of color alone, announce it through a polite live region, and move focus to the first invalid field when a submit fails.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does my iPhone zoom in when I tap an input?
&lt;/h3&gt;

&lt;p&gt;Safari on iOS zooms the page when the focused field's text is smaller than 16px. Give inputs 16px text on phones and go smaller from a wider breakpoint if you want, instead of turning off pinch zoom in the viewport meta tag.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I use a select, a radio group or a combobox?
&lt;/h3&gt;

&lt;p&gt;Use a radio group or a segmented control for two to five options people should compare at a glance, a select for a known list up to a few hundred options, and a combobox when the list is huge, comes from a server or accepts free text.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which Wingo UI form components are free?
&lt;/h3&gt;

&lt;p&gt;Field, Input, Textarea, Select, Checkbox, Switch, Radio Group, Segmented Control, Search Input, Listbox and the OTP input are free. The phone input, the date and time pickers, Combobox, Multi-select, the number, currency, password and email inputs, File Upload and the form-validation lib are Pro.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article was originally published on the Wingo UI blog: &lt;a href="https://wingo-ui.com/blog/react-form-components-guide" rel="noopener noreferrer"&gt;React Form Components: Accessible Fields, Zod and Mobile&lt;/a&gt;. The original has the live, interactive demos and is kept up to date.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>react</category>
      <category>a11y</category>
      <category>typescript</category>
      <category>forms</category>
    </item>
    <item>
      <title>Component Library MCP: Make Agents Use Real Components</title>
      <dc:creator>Serban Dan Rusu</dc:creator>
      <pubDate>Tue, 20 Oct 2026 07:00:00 +0000</pubDate>
      <link>https://dev.to/wingo-ui/component-library-mcp-make-agents-use-real-components-538p</link>
      <guid>https://dev.to/wingo-ui/component-library-mcp-make-agents-use-real-components-538p</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Ask Claude Code for a settings page and it will write one in a minute. It may also invent a &lt;code&gt;&amp;lt;Button intent="primary"&amp;gt;&lt;/code&gt; your library never had, hardcode &lt;code&gt;#3b82f6&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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 &lt;a href="https://wingo-ui.com/blog/category/ai-agents" rel="noopener noreferrer"&gt;Building UI with AI agents&lt;/a&gt;: &lt;a href="https://wingo-ui.com/blog/agents-md-vs-claude-md-react" rel="noopener noreferrer"&gt;AGENTS.md vs CLAUDE.md vs Cursor rules&lt;/a&gt; for the rules file, a &lt;a href="https://wingo-ui.com/blog/claude-code-shadcn-mcp-setup" rel="noopener noreferrer"&gt;Claude Code shadcn setup&lt;/a&gt; for the shadcn MCP server next to a second registry, and a &lt;a href="https://wingo-ui.com/blog/react-ai-chat-ui" rel="noopener noreferrer"&gt;React AI chat UI guide&lt;/a&gt; for when the screen you are building is the chat itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is a component library MCP server?
&lt;/h2&gt;

&lt;p&gt;A component library MCP server is a &lt;a href="https://modelcontextprotocol.io/" rel="noopener noreferrer"&gt;Model Context Protocol&lt;/a&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The React component library MCP servers we checked come in three shapes. Here is how they compare, as of October 2026:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Server&lt;/th&gt;
&lt;th&gt;Runs as&lt;/th&gt;
&lt;th&gt;What the agent gets&lt;/th&gt;
&lt;th&gt;Writes files into your project&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;shadcn MCP&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;npx shadcn@latest mcp&lt;/code&gt; (local)&lt;/td&gt;
&lt;td&gt;Browse, search and install items from every registry in &lt;code&gt;components.json&lt;/code&gt;, private ones included&lt;/td&gt;
&lt;td&gt;Yes, through the shadcn CLI&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MUI MCP&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;npx -y @mui/mcp@latest&lt;/code&gt; (local, stdio)&lt;/td&gt;
&lt;td&gt;Official Material UI docs and code examples, with links to real pages&lt;/td&gt;
&lt;td&gt;No, Material UI is an npm package&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;React Suite MCP&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;npx -y @rsuite/mcp@latest&lt;/code&gt; (local)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;get_component_props&lt;/code&gt;, &lt;code&gt;list_components&lt;/code&gt;, &lt;code&gt;list_hooks&lt;/code&gt;, &lt;code&gt;search_components&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;No, React Suite is an npm package&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Wingo UI MCP&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;https://wingo-ui.com/mcp&lt;/code&gt; (remote, Streamable HTTP) or &lt;code&gt;npx -y wingo-ui@latest mcp&lt;/code&gt; (local)&lt;/td&gt;
&lt;td&gt;Search, props with defaults, usage, blocks, design rules, theme tokens, update checks, changelog&lt;/td&gt;
&lt;td&gt;Yes, through the Wingo UI CLI&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Sources, checked in October 2026: the &lt;a href="https://ui.shadcn.com/docs/mcp" rel="noopener noreferrer"&gt;shadcn MCP docs&lt;/a&gt;, the &lt;a href="https://mui.com/material-ui/getting-started/mcp/" rel="noopener noreferrer"&gt;Material UI MCP guide&lt;/a&gt;, the &lt;a href="https://rsuitejs.com/guide/mcp-server/" rel="noopener noreferrer"&gt;React Suite MCP guide&lt;/a&gt; and our &lt;a href="https://wingo-ui.com/docs/mcp" rel="noopener noreferrer"&gt;MCP server docs&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The second shape is a registry installer. The shadcn docs describe listing, searching and installing from any shadcn-compatible registry in &lt;code&gt;components.json&lt;/code&gt;; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why do coding agents invent components?
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The monday.com engineering team described the same failure in &lt;a href="https://engineering.monday.com/how-we-use-ai-to-turn-figma-designs-into-production-code/" rel="noopener noreferrer"&gt;How We Use AI to Turn Figma Designs into Production Code&lt;/a&gt; (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."&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does an agent need besides the MCP server?
&lt;/h2&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Piece&lt;/th&gt;
&lt;th&gt;What it answers&lt;/th&gt;
&lt;th&gt;How often it changes&lt;/th&gt;
&lt;th&gt;In Wingo UI&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;MCP server&lt;/td&gt;
&lt;td&gt;What exists, its props and usage, the design rules, which versions are current&lt;/td&gt;
&lt;td&gt;Every release&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;https://wingo-ui.com/mcp&lt;/code&gt;, 10 tools and 1 prompt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rules file&lt;/td&gt;
&lt;td&gt;When to call which tool, how to install, what never to do&lt;/td&gt;
&lt;td&gt;Rarely&lt;/td&gt;
&lt;td&gt;A marked block in &lt;code&gt;CLAUDE.md&lt;/code&gt;, &lt;code&gt;AGENTS.md&lt;/code&gt; or &lt;code&gt;GEMINI.md&lt;/code&gt;, a Cursor rule, a Claude Code skill&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;llms.txt and Markdown docs&lt;/td&gt;
&lt;td&gt;The same facts as plain text for agents without MCP&lt;/td&gt;
&lt;td&gt;Every release&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;/llms.txt&lt;/code&gt;, a file per category, a &lt;code&gt;.md&lt;/code&gt; twin of every docs page&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Registry and CLI&lt;/td&gt;
&lt;td&gt;The actual files, their dependencies and a version record&lt;/td&gt;
&lt;td&gt;Every release&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;/r/&amp;lt;slug&amp;gt;.json&lt;/code&gt; in the shadcn format, &lt;code&gt;npx wingo-ui@latest add&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which tools should a component library MCP server expose?
&lt;/h2&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;search_components&lt;/code&gt;: find components, blocks, hooks and helpers by keywords.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;list_categories&lt;/code&gt;: browse the categories with item counts.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;get_component&lt;/code&gt;: props, parts, usage, dependencies, the install command and the source.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;get_block&lt;/code&gt;: a full screen or section and the components it composes.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;get_design_rules&lt;/code&gt;: tokens, sizes, motion, forms, overlays and mobile rules.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;get_theme_tokens&lt;/code&gt;: the CSS variables for light and dark mode.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;get_install_instructions&lt;/code&gt;: project setup for Next.js or Vite.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;check_updates&lt;/code&gt;: compare installed items with the latest release.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;get_changelog&lt;/code&gt;: recent releases and their notes.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;get_account_status&lt;/code&gt;: whether the key has Pro, with the links to upgrade.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There is also one prompt, &lt;code&gt;build_screen&lt;/code&gt;, 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Search should return slugs and one line each
&lt;/h3&gt;

&lt;p&gt;A search result is a short list the agent can scan in one glance. This is the real answer from our server to &lt;code&gt;search_components&lt;/code&gt; with the query "orders table with filters" and &lt;code&gt;limit: 5&lt;/code&gt;, trimmed to the first three hits:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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 &lt;code&gt;get_component&lt;/code&gt; call to find out.&lt;/p&gt;

&lt;h3&gt;
  
  
  Props need their defaults
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;get_component&lt;/code&gt; 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 &lt;a href="https://wingo-ui.com/components/button" rel="noopener noreferrer"&gt;Button&lt;/a&gt; defaults to &lt;code&gt;type="button"&lt;/code&gt;, &lt;code&gt;tone="neutral"&lt;/code&gt; and &lt;code&gt;size="md"&lt;/code&gt; writes &lt;code&gt;&amp;lt;Button&amp;gt;Cancel&amp;lt;/Button&amp;gt;&lt;/code&gt; instead of spelling out props it guessed. One that knows &lt;code&gt;loading&lt;/code&gt; keeps the width and sets &lt;code&gt;aria-busy&lt;/code&gt; does not wrap the button in its own spinner logic.&lt;/p&gt;

&lt;p&gt;Below is that Button with the brand tone and a loading label set through props.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Live demo: Button.&lt;/strong&gt; 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). &lt;a href="https://wingo-ui.com/blog/component-library-mcp-guide" rel="noopener noreferrer"&gt;Try it live on wingo-ui.com&lt;/a&gt; or open the &lt;a href="https://wingo-ui.com/components/button" rel="noopener noreferrer"&gt;Button docs&lt;/a&gt;. Install: &lt;code&gt;npx wingo-ui@latest add button&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Keep tool output small
&lt;/h3&gt;

&lt;p&gt;Large answers run into hard limits in the clients. As of October 2026, &lt;a href="https://code.claude.com/docs/en/mcp" rel="noopener noreferrer"&gt;Claude Code's MCP docs&lt;/a&gt; say it warns when a tool result passes 10,000 tokens, caps results at 25,000 tokens by default (&lt;code&gt;MAX_MCP_OUTPUT_TOKENS&lt;/code&gt; raises it) and saves text results over 50,000 characters to a file instead of putting them in context.&lt;/p&gt;

&lt;p&gt;Our &lt;a href="https://wingo-ui.com/components/data-table" rel="noopener noreferrer"&gt;Data Table&lt;/a&gt; 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 &lt;code&gt;get_component&lt;/code&gt; with &lt;code&gt;includeSource: false&lt;/code&gt; for large items and let &lt;code&gt;npx wingo-ui@latest add&lt;/code&gt; write the code.&lt;/p&gt;

&lt;p&gt;The same goes for rules. &lt;code&gt;get_design_rules&lt;/code&gt; with the topic &lt;code&gt;all&lt;/code&gt; 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 &lt;code&gt;mobile&lt;/code&gt; topic alone is about 1,900 characters and &lt;code&gt;forms&lt;/code&gt; about 4,200. Ask for the topic the task needs.&lt;/p&gt;

&lt;h3&gt;
  
  
  Errors should say what to do next
&lt;/h3&gt;

&lt;p&gt;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 &lt;code&gt;search_components&lt;/code&gt;. A Pro item requested without a Pro key answers with the docs and this, word for word from our server (links trimmed, lines wrapped):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;"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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Server instructions are the first thing the agent reads
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I connect Claude Code, Cursor and Codex?
&lt;/h2&gt;

&lt;p&gt;One CLI command writes both the MCP config and the rules for your agent. With &lt;code&gt;WINGO_UI_API_KEY&lt;/code&gt; 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.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx wingo-ui@latest login                     &lt;span class="c"&gt;# once per computer&lt;/span&gt;
npx wingo-ui@latest agents &lt;span class="nb"&gt;install &lt;/span&gt;claude     &lt;span class="c"&gt;# or codex, gemini, grok, cursor, all&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Running it again changes nothing, and it keeps the other servers in your MCP configs. Where each agent's files go:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Agent&lt;/th&gt;
&lt;th&gt;MCP config&lt;/th&gt;
&lt;th&gt;Rules&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Claude Code&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.mcp.json&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;.claude/skills/wingo-ui/SKILL.md&lt;/code&gt; and a marked block in &lt;code&gt;CLAUDE.md&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Codex&lt;/td&gt;
&lt;td&gt;&lt;code&gt;~/.codex/config.toml&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A marked block in &lt;code&gt;AGENTS.md&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemini CLI&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.gemini/settings.json&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A marked block in &lt;code&gt;GEMINI.md&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Grok&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.mcp.json&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A marked block in &lt;code&gt;AGENTS.md&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cursor&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.cursor/mcp.json&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.cursor/rules/wingo-ui.mdc&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;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 &lt;code&gt;WINGO_UI_API_KEY&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Claude Code
&lt;/h3&gt;

&lt;p&gt;Claude Code stores MCP servers in three scopes: local (the default, private to you in &lt;code&gt;~/.claude.json&lt;/code&gt;), project (&lt;code&gt;.mcp.json&lt;/code&gt; at the repository root, committed for the whole team) and user (all your projects). For a library the team shares, use project scope; &lt;code&gt;.mcp.json&lt;/code&gt; expands &lt;code&gt;${VAR}&lt;/code&gt; from the environment, so the file is safe to commit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"wingo-ui"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"http"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://wingo-ui.com/mcp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"headers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"Authorization"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bearer ${WINGO_UI_API_KEY}"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a quick personal setup, the one-liner adds it in local scope:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude mcp add &lt;span class="nt"&gt;--transport&lt;/span&gt; http wingo-ui https://wingo-ui.com/mcp &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$WINGO_UI_API_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run &lt;code&gt;/mcp&lt;/code&gt; 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 &lt;code&gt;agents install&lt;/code&gt; keeps the entries it did not write. Our &lt;a href="https://wingo-ui.com/blog/claude-code-shadcn-mcp-setup" rel="noopener noreferrer"&gt;Claude Code shadcn tutorial&lt;/a&gt; walks through the shadcn MCP server, its skill and a second registry in the same project.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cursor
&lt;/h3&gt;

&lt;p&gt;Cursor reads &lt;code&gt;.cursor/mcp.json&lt;/code&gt; in the project and &lt;code&gt;~/.cursor/mcp.json&lt;/code&gt; for every project. It uses its own variable syntax, &lt;code&gt;${env:NAME}&lt;/code&gt;, documented in &lt;a href="https://cursor.com/docs/context/mcp" rel="noopener noreferrer"&gt;Cursor's MCP docs&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"wingo-ui"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://wingo-ui.com/mcp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"headers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"Authorization"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bearer ${env:WINGO_UI_API_KEY}"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Codex
&lt;/h3&gt;

&lt;p&gt;Codex reads MCP servers from &lt;code&gt;~/.codex/config.toml&lt;/code&gt; (or a trusted project's &lt;code&gt;.codex/config.toml&lt;/code&gt;). For a remote server, &lt;code&gt;bearer_token_env_var&lt;/code&gt; names the variable that holds the token, as the &lt;a href="https://developers.openai.com/codex/mcp" rel="noopener noreferrer"&gt;Codex MCP docs&lt;/a&gt; describe. Restart Codex after adding it so it picks up the server.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="nn"&gt;[mcp_servers.wingo-ui]&lt;/span&gt;
&lt;span class="py"&gt;url&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"https://wingo-ui.com/mcp"&lt;/span&gt;
&lt;span class="py"&gt;bearer_token_env_var&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"WINGO_UI_API_KEY"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Any other client
&lt;/h3&gt;

&lt;p&gt;Clients that cannot send a header to a remote server can run the local proxy. It reads the key saved by &lt;code&gt;wingo-ui login&lt;/code&gt; and mirrors the remote tools, so new tools appear without updating anything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"wingo-ui"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"-y"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"wingo-ui@latest"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"mcp"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What goes in the rules file?
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;AGENTS.md&lt;/code&gt;, &lt;code&gt;CLAUDE.md&lt;/code&gt; or &lt;code&gt;GEMINI.md&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- wingo-ui:start v=2 --&amp;gt;&lt;/span&gt;
&lt;span class="gu"&gt;## Wingo UI&lt;/span&gt;

This project builds its UI with Wingo UI (React 19 + Tailwind CSS v4 components installed as source, see &lt;span class="sb"&gt;`wingo-ui.json`&lt;/span&gt;).
&lt;span class="p"&gt;
-&lt;/span&gt; Before writing UI, use the &lt;span class="sb"&gt;`wingo-ui`&lt;/span&gt; MCP tools: &lt;span class="sb"&gt;`search_components`&lt;/span&gt; to find what exists (blocks for whole screens), &lt;span class="sb"&gt;`get_component`&lt;/span&gt; / &lt;span class="sb"&gt;`get_block`&lt;/span&gt; for props and usage, &lt;span class="sb"&gt;`get_design_rules`&lt;/span&gt; for tokens, sizes, motion and mobile rules. Never recreate a component the library has.
&lt;span class="p"&gt;-&lt;/span&gt; Install with &lt;span class="sb"&gt;`npx wingo-ui@latest add &amp;lt;slug...&amp;gt;`&lt;/span&gt;. At the start of UI work run &lt;span class="sb"&gt;`check_updates`&lt;/span&gt; (or &lt;span class="sb"&gt;`npx wingo-ui@latest outdated`&lt;/span&gt;) and mention updates; update with &lt;span class="sb"&gt;`npx wingo-ui@latest update`&lt;/span&gt;.
&lt;span class="p"&gt;-&lt;/span&gt; Semantic tokens only (&lt;span class="sb"&gt;`bg-background`&lt;/span&gt;, &lt;span class="sb"&gt;`bg-surface`&lt;/span&gt;, &lt;span class="sb"&gt;`text-muted-foreground`&lt;/span&gt;, &lt;span class="sb"&gt;`border-border`&lt;/span&gt;), configure components through props, keep &lt;span class="sb"&gt;`cn()`&lt;/span&gt;, &lt;span class="sb"&gt;`data-slot`&lt;/span&gt; and license headers in copied files. Check 390px and 1440px, light and dark.
&lt;span class="p"&gt;-&lt;/span&gt; No MCP tools available? The CLI has &lt;span class="sb"&gt;`search`&lt;/span&gt;, &lt;span class="sb"&gt;`info`&lt;/span&gt;, &lt;span class="sb"&gt;`add`&lt;/span&gt;, &lt;span class="sb"&gt;`outdated`&lt;/span&gt;, &lt;span class="sb"&gt;`update`&lt;/span&gt;; the docs for models are at https://wingo-ui.com/llms.txt.
&lt;span class="c"&gt;&amp;lt;!-- wingo-ui:end --&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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: &lt;code&gt;npx wingo-ui@latest agents status&lt;/code&gt; tells you whether your copy is current, and &lt;code&gt;agents update&lt;/code&gt; rewrites only the text between the markers.&lt;/p&gt;

&lt;p&gt;Claude Code also gets a skill with the longer workflow, and Cursor gets a project rule scoped to &lt;code&gt;.tsx&lt;/code&gt;, &lt;code&gt;.jsx&lt;/code&gt; and &lt;code&gt;.css&lt;/code&gt; files. The full setup is on the &lt;a href="https://wingo-ui.com/docs/agents" rel="noopener noreferrer"&gt;coding agents docs page&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;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 &lt;a href="https://wingo-ui.com/blog/agents-md-vs-claude-md-react" rel="noopener noreferrer"&gt;AGENTS.md vs CLAUDE.md comparison&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where do llms.txt and Markdown docs fit?
&lt;/h2&gt;

&lt;p&gt;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 &lt;a href="https://llmstxt.org/" rel="noopener noreferrer"&gt;llms.txt proposal&lt;/a&gt;, 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 &lt;code&gt;.md&lt;/code&gt; added, and its current version (updated August 2026) describes a &lt;code&gt;rel="alternate"&lt;/code&gt; link of type &lt;code&gt;text/markdown&lt;/code&gt; that points to it.&lt;/p&gt;

&lt;p&gt;Our version follows that layout:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://wingo-ui.com/llms.txt" rel="noopener noreferrer"&gt;/llms.txt&lt;/a&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/llms/&amp;lt;category&amp;gt;.txt&lt;/code&gt; holds the item docs for one category, for example every table or every overlay.&lt;/li&gt;
&lt;li&gt;Every docs page has a Markdown twin, like &lt;a href="https://wingo-ui.com/components/button.md" rel="noopener noreferrer"&gt;/components/button.md&lt;/a&gt;, linked from the HTML head.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://wingo-ui.com/llms-full.txt" rel="noopener noreferrer"&gt;/llms-full.txt&lt;/a&gt; has everything in one file, about 3.3 MB as of October 2026 (the docs of release 1.7.0 plus the blog posts).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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 &lt;code&gt;.md&lt;/code&gt; page the task needs. For a chat assistant that can fetch URLs, "read &lt;a href="https://wingo-ui.com/components/data-table.md" rel="noopener noreferrer"&gt;https://wingo-ui.com/components/data-table.md&lt;/a&gt;, then write an orders table with it" puts the real props in front of the model with one fetch.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why install through a registry instead of pasting code?
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Take the &lt;a href="https://wingo-ui.com/components/login-page" rel="noopener noreferrer"&gt;Login Page&lt;/a&gt; block. &lt;code&gt;npx wingo-ui@latest add login-page&lt;/code&gt; writes three files into &lt;code&gt;components/blocks&lt;/code&gt;, 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: &lt;code&gt;motion&lt;/code&gt;, &lt;code&gt;lucide-react&lt;/code&gt;, &lt;code&gt;react-hook-form&lt;/code&gt; and &lt;code&gt;zod&lt;/code&gt;. It records each item's version and content hash in &lt;code&gt;wingo-ui.json&lt;/code&gt; and keeps a pristine copy under &lt;code&gt;.wingo-ui/base/&lt;/code&gt;. When a release changes the block, &lt;code&gt;npx wingo-ui@latest update&lt;/code&gt; runs a three-way merge between that pristine copy, your edited file and the new version, the way git merges branches.&lt;/p&gt;

&lt;p&gt;An agent pasting code from &lt;code&gt;get_block&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;The registry also speaks the shadcn format. Free items install straight from their URL, for example &lt;code&gt;npx shadcn@latest add https://wingo-ui.com/r/button.json&lt;/code&gt;, and Pro items install by name once the &lt;code&gt;@wingo-ui&lt;/code&gt; registry with your key is in &lt;code&gt;components.json&lt;/code&gt;, which also lets the shadcn MCP server install them. The trade-off is on our &lt;a href="https://wingo-ui.com/docs/installation" rel="noopener noreferrer"&gt;installation page&lt;/a&gt;: the shadcn CLI does not record versions, so &lt;code&gt;wingo-ui update&lt;/code&gt; cannot merge later changes into files installed that way.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does a full agent session look like?
&lt;/h2&gt;

&lt;p&gt;Take a Next.js app that already has a &lt;code&gt;wingo-ui.json&lt;/code&gt; 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:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;check_updates&lt;/code&gt; with the items from &lt;code&gt;wingo-ui.json&lt;/code&gt;, and a one-line report of what changed upstream.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;search_components&lt;/code&gt; with "sign in page" and the kind &lt;code&gt;block&lt;/code&gt;. On our server the first hit is &lt;code&gt;login-page&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;get_block&lt;/code&gt; for &lt;code&gt;login-page&lt;/code&gt;: the props, the components it composes and the install command.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;search_components&lt;/code&gt; with "orders table with filters", then &lt;code&gt;get_component&lt;/code&gt; for &lt;code&gt;data-table&lt;/code&gt; with &lt;code&gt;includeSource: false&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;get_design_rules&lt;/code&gt; for the &lt;code&gt;forms&lt;/code&gt; and &lt;code&gt;mobile&lt;/code&gt; topics.&lt;/li&gt;
&lt;li&gt;One install for everything: &lt;code&gt;npx wingo-ui@latest add login-page data-table&lt;/code&gt; (Button, Input and the rest come along as dependencies).&lt;/li&gt;
&lt;li&gt;Two pages built from the installed components, configured through their props.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/(auth)/login/page.tsx&lt;/span&gt;
&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;use client&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useRouter&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;next/navigation&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;LoginPage&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/components/blocks/login-page&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// a non-2xx answer becomes the message the block shows above the form&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;content-type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Page&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;router&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useRouter&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;LoginPage&lt;/span&gt;
      &lt;span class="na"&gt;methods&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;password&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;showProviders&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;twoFactor&lt;/span&gt;
      &lt;span class="na"&gt;onSignIn&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;values&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/api/login&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;values&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;The email or the password is not correct&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;onVerifyCode&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;backup&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/api/login/verify&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;backup&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;That code is not correct&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;onForgot&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/api/password/forgot&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;We could not send the email. Try again.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;onSuccess&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;router&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/orders&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A rejected promise from &lt;code&gt;onSignIn&lt;/code&gt; shows its message in an alert above the form, and &lt;code&gt;twoFactor&lt;/code&gt; sends a correct password to the code step. Three wrong codes lock the input for a minute whatever your handler does.&lt;/p&gt;

&lt;p&gt;The pitfall is in the handlers the code leaves out. The block ships simulated ones so its demo works, and every &lt;code&gt;on*&lt;/code&gt; prop you skip keeps its simulation. Without &lt;code&gt;onVerifyCode&lt;/code&gt;, the two-step view accepts &lt;code&gt;123456&lt;/code&gt;. Without &lt;code&gt;showProviders={false}&lt;/code&gt; or your own &lt;code&gt;onProvider&lt;/code&gt;, the Google, Apple and Microsoft buttons spin for 1.2 seconds and then call &lt;code&gt;onSuccess&lt;/code&gt;. An agent that wires &lt;code&gt;onSignIn&lt;/code&gt; and stops ships a login page where a click on Google goes straight to &lt;code&gt;/orders&lt;/code&gt; without signing anyone in. The &lt;code&gt;get_block&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;The demo below runs on those simulated handlers, with the default methods and providers.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Live demo: Login Page.&lt;/strong&gt; 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). &lt;a href="https://wingo-ui.com/blog/component-library-mcp-guide" rel="noopener noreferrer"&gt;Try it live on wingo-ui.com&lt;/a&gt; or open the &lt;a href="https://wingo-ui.com/components/login-page" rel="noopener noreferrer"&gt;Login Page docs&lt;/a&gt;. Install: &lt;code&gt;npx wingo-ui@latest add login-page&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The orders page follows the same pattern. The Data Table props the agent read decide most of the code: typed columns from &lt;code&gt;createColumns&lt;/code&gt;, a badge column with a tone per status, a currency column with a footer sum, filters per column and quick filter chips.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/orders/orders-table.tsx&lt;/span&gt;
&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;use client&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useRouter&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;next/navigation&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Eye&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Plus&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;lucide-react&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;DataTable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;createColumns&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/components/ui/data-table&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;city&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Paid&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Pending&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Refunded&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;total&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;placedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

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

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;OrdersTable&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;router&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useRouter&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;DataTable&lt;/span&gt;
      &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;orders&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;columns&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;columns&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;getRowId&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;caption&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Orders"&lt;/span&gt;
      &lt;span class="na"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"en-US"&lt;/span&gt;
      &lt;span class="na"&gt;searchPlaceholder&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Search orders"&lt;/span&gt;
      &lt;span class="na"&gt;quickFilters&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pending&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Pending&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;columnFilter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;status&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Pending&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;toolbarActions&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;New order&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;icon&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Plus&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;,&lt;/span&gt; &lt;span class="na"&gt;primary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;onSelect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;router&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/orders/new&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;rowActions&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;View&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;icon&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Eye&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;,&lt;/span&gt; &lt;span class="na"&gt;onSelect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;router&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/orders/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;persistKey&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"orders"&lt;/span&gt;
    &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The page itself stays a server component that loads the rows and passes them down:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/orders/page.tsx&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;OrdersTable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Order&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./orders-table&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getOrders&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// your database query goes here&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ORD-1042&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Emma Carter&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;city&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;New York&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Paid&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;total&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;129&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;placedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2026-10-02&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Page&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;orders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getOrders&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;main&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"mx-auto flex max-w-6xl flex-col gap-4 p-4 sm:p-8"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"text-xl font-semibold text-foreground"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Orders&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;OrdersTable&lt;/span&gt; &lt;span class="na"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;orders&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;main&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The split is deliberate. &lt;code&gt;getRowId&lt;/code&gt;, &lt;code&gt;rowActions&lt;/code&gt; and &lt;code&gt;onSelect&lt;/code&gt; are functions (so is any custom &lt;code&gt;cell&lt;/code&gt; renderer in the columns), and the App Router cannot pass functions from a server component to a client one. An agent that renders &lt;code&gt;&amp;lt;DataTable&amp;gt;&lt;/code&gt; with inline callbacks straight from a server &lt;code&gt;page.tsx&lt;/code&gt; gets that error at render time. Keeping the columns and callbacks in a client file and passing only plain rows avoids it.&lt;/p&gt;

&lt;p&gt;Two more details come straight from the docs &lt;code&gt;get_component&lt;/code&gt; returns. The &lt;code&gt;mobile&lt;/code&gt; roles pick each card's title and subtitle below 768px, where the table becomes cards by default (&lt;code&gt;mobileLayout="cards"&lt;/code&gt;). And &lt;code&gt;persistKey&lt;/code&gt; remembers column visibility, order, widths, density and page size in local storage.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Live demo: Data Table.&lt;/strong&gt; 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). &lt;a href="https://wingo-ui.com/blog/component-library-mcp-guide" rel="noopener noreferrer"&gt;Try it live on wingo-ui.com&lt;/a&gt; or open the &lt;a href="https://wingo-ui.com/components/data-table" rel="noopener noreferrer"&gt;Data Table docs&lt;/a&gt;. Install: &lt;code&gt;npx wingo-ui@latest add data-table&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do agent-installed components stay up to date?
&lt;/h2&gt;

&lt;p&gt;The agent checks, you decide. At the start of UI work the rules ask for &lt;code&gt;check_updates&lt;/code&gt; with the items in &lt;code&gt;wingo-ui.json&lt;/code&gt;. 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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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 &amp;lt;slug...&amp;gt;` (a 3-way merge that keeps local edits); read breaking notes first.

Up to date: button
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The numbers in front of the notes are library releases; the item itself goes from 1.0.0 to 1.1.0.&lt;/p&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx wingo-ui@latest outdated        &lt;span class="c"&gt;# installed items vs the latest release&lt;/span&gt;
npx wingo-ui@latest diff data-table &lt;span class="c"&gt;# your file vs upstream&lt;/span&gt;
npx wingo-ui@latest update          &lt;span class="c"&gt;# 3-way merge, with the release notes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rules have their own version: &lt;code&gt;agents status&lt;/code&gt; reports an old copy and &lt;code&gt;agents update&lt;/code&gt; refreshes it. To keep your own edits to a managed file, delete its &lt;code&gt;wingo-ui:managed&lt;/code&gt; line. The &lt;a href="https://wingo-ui.com/docs/updates" rel="noopener noreferrer"&gt;updates docs&lt;/a&gt; cover conflicts and the &lt;code&gt;--theirs&lt;/code&gt; flag, and our guide to &lt;a href="https://wingo-ui.com/blog/update-shadcn-components-without-losing-edits" rel="noopener noreferrer"&gt;updating shadcn components without losing edits&lt;/a&gt; shows the same 3-way merge done by hand with git.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I check what the agent built?
&lt;/h2&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hardcoded colors and radii.&lt;/strong&gt; Search the diff for hex values, &lt;code&gt;rgb(&lt;/code&gt; and arbitrary &lt;code&gt;rounded-[...]&lt;/code&gt; classes. The rules ask for semantic tokens, and a stray hex color is easy to miss.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rebuilt components.&lt;/strong&gt; A new file under &lt;code&gt;components/&lt;/code&gt; that looks like a library item means the agent skipped search.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Restyling instead of props.&lt;/strong&gt; A long class list on a Button often stands in for a &lt;code&gt;variant&lt;/code&gt;, &lt;code&gt;tone&lt;/code&gt; or &lt;code&gt;size&lt;/code&gt; the agent did not read.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Simulated handlers.&lt;/strong&gt; Blocks ship demo handlers for every &lt;code&gt;on*&lt;/code&gt; prop. Compare the handlers the agent wired with the features left on screen, as in the login example above.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Phone width and both themes.&lt;/strong&gt; At 390px tables should become cards and menus should open as bottom sheets (our &lt;a href="https://wingo-ui.com/blog/mobile-first-react-components" rel="noopener noreferrer"&gt;mobile-first React components guide&lt;/a&gt; has the full list); light and dark should both hold up.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Library files.&lt;/strong&gt; &lt;code&gt;npx wingo-ui@latest diff &amp;lt;slug&amp;gt;&lt;/code&gt; shows whether the agent edited one. Edits are allowed, but they become merge work on the next update.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Should you build an MCP server for your own design system?
&lt;/h2&gt;

&lt;p&gt;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 (&lt;code&gt;@modelcontextprotocol/server&lt;/code&gt;) 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:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Start from metadata you already keep.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep every tool read-only.&lt;/strong&gt; Let the agent read; let your CLI or package manager write.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Return structured content and text.&lt;/strong&gt; The model reads the text; scripts and future clients parse the structure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make outputs small and name the next step.&lt;/strong&gt; Slugs in search, the source behind a flag, the next tool at the end.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write errors as instructions.&lt;/strong&gt; "Did you mean ..." and "do not re-create it" put the fix in front of the model at the moment it needs it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep server instructions under 2,048 characters&lt;/strong&gt;, 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Serve the release you deployed.&lt;/strong&gt; One fresh server per request, backed by that deploy's registry, means agents never see stale props.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Offer HTTP and stdio.&lt;/strong&gt; Remote HTTP for clients that send headers, a local proxy for the rest.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;a href="https://wingo-ui.com/docs/mcp" rel="noopener noreferrer"&gt;MCP server for UI components&lt;/a&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where should I start?
&lt;/h2&gt;

&lt;p&gt;To try it, connect the server with &lt;code&gt;npx wingo-ui@latest agents install claude&lt;/code&gt; (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 &lt;a href="https://wingo-ui.com/components/button" rel="noopener noreferrer"&gt;Button&lt;/a&gt; and the &lt;a href="https://wingo-ui.com/components/otp-input" rel="noopener noreferrer"&gt;OTP Input&lt;/a&gt;; the CLI installs those without an account. The &lt;a href="https://wingo-ui.com/components/login-page" rel="noopener noreferrer"&gt;Login Page&lt;/a&gt; and &lt;a href="https://wingo-ui.com/components/data-table" rel="noopener noreferrer"&gt;Data Table&lt;/a&gt; are part of Pro, which covers all 326 items: $8 a month, $80 a year or $150 once, on the &lt;a href="https://wingo-ui.com/pricing" rel="noopener noreferrer"&gt;pricing page&lt;/a&gt;. The &lt;a href="https://wingo-ui.com/docs/mcp" rel="noopener noreferrer"&gt;MCP server docs&lt;/a&gt; have the per-client setup and troubleshooting.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Components in this post
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://wingo-ui.com/components/button" rel="noopener noreferrer"&gt;Button&lt;/a&gt; (Free): The button every screen starts with: five variants, six tones, three sizes plus icon sizes, and a loading state that never jumps. Install: &lt;code&gt;npx wingo-ui@latest add button&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://wingo-ui.com/components/login-page" rel="noopener noreferrer"&gt;Login Page&lt;/a&gt; (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: &lt;code&gt;npx wingo-ui@latest add login-page&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://wingo-ui.com/components/data-table" rel="noopener noreferrer"&gt;Data Table&lt;/a&gt; (Pro): The admin table on TanStack Table v9: search, filters, sorting, bulk actions, pinning, resizing, virtualization and cards on phones. Install: &lt;code&gt;npx wingo-ui@latest add data-table&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What is a component library MCP server?
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I still need a rules file if I connect an MCP server?
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is llms.txt enough without an MCP server?
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does the Wingo UI MCP server work without an account?
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I use the shadcn MCP server with Wingo UI?
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which coding agents can connect to the Wingo UI MCP server?
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article was originally published on the Wingo UI blog: &lt;a href="https://wingo-ui.com/blog/component-library-mcp-guide" rel="noopener noreferrer"&gt;Component Library MCP: Make Agents Use Real Components&lt;/a&gt;. The original has the live, interactive demos and is kept up to date.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>mcp</category>
      <category>react</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Mobile-First React Components: 9 Rules With Code</title>
      <dc:creator>Serban Dan Rusu</dc:creator>
      <pubDate>Mon, 19 Oct 2026 19:41:35 +0000</pubDate>
      <link>https://dev.to/wingo-ui/mobile-first-react-components-9-rules-with-code-7jf</link>
      <guid>https://dev.to/wingo-ui/mobile-first-react-components-9-rules-with-code-7jf</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What makes a React component mobile-first?
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The nine rules in one table, then a section for each:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Rule&lt;/th&gt;
&lt;th&gt;On a phone&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Overlays&lt;/td&gt;
&lt;td&gt;Popovers, selects, menus and dialogs open as bottom sheets below 768px&lt;/td&gt;
&lt;td&gt;An anchored panel has no room and sits under the thumb&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Touch targets&lt;/td&gt;
&lt;td&gt;44px on coarse pointers, 8px apart&lt;/td&gt;
&lt;td&gt;A finger is less precise than a cursor&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hover&lt;/td&gt;
&lt;td&gt;Nothing is hover-only&lt;/td&gt;
&lt;td&gt;Touch screens have no hover&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Safe areas&lt;/td&gt;
&lt;td&gt;Fixed bars pad &lt;code&gt;env(safe-area-inset-*)&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;The notch and the home indicator cover content&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Height&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;dvh&lt;/code&gt; or &lt;code&gt;svh&lt;/code&gt;, never &lt;code&gt;vh&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;100vh&lt;/code&gt; is taller than the visible screen&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Keyboard&lt;/td&gt;
&lt;td&gt;Bottom bars hide or ride above it, inputs use 16px text&lt;/td&gt;
&lt;td&gt;Fixed bars end up behind the keyboard&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tables&lt;/td&gt;
&lt;td&gt;Rows become cards&lt;/td&gt;
&lt;td&gt;Sideways scrolling hides most of every row&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gestures&lt;/td&gt;
&lt;td&gt;Every gesture has a visible alternative&lt;/td&gt;
&lt;td&gt;Gestures are invisible, and not everyone can perform them&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Layout and behavior&lt;/td&gt;
&lt;td&gt;CSS breakpoints for layout, a hook for behavior&lt;/td&gt;
&lt;td&gt;The server does not know the screen width&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why should a popover become a bottom sheet on a phone?
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Tooltips never become sheets. Nothing hovers on a touch screen, so the text goes inline or behind a long press.&lt;/li&gt;
&lt;li&gt;Alert dialogs stay centered at every width. A short question that must be answered reads like an iOS alert.&lt;/li&gt;
&lt;li&gt;Suggestions under a text field stay attached to it, because a sheet would cover what you type, as in the &lt;a href="https://wingo-ui.com/components/mention-input" rel="noopener noreferrer"&gt;Mention Input&lt;/a&gt;. The &lt;a href="https://wingo-ui.com/components/combobox" rel="noopener noreferrer"&gt;Combobox&lt;/a&gt; takes the other route: a full-height sheet with its own search box.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In Wingo UI the switch is a &lt;code&gt;responsive&lt;/code&gt; prop that defaults to &lt;code&gt;true&lt;/code&gt; on the &lt;a href="https://wingo-ui.com/components/popover" rel="noopener noreferrer"&gt;Popover&lt;/a&gt;, &lt;a href="https://wingo-ui.com/components/select" rel="noopener noreferrer"&gt;Select&lt;/a&gt;, &lt;a href="https://wingo-ui.com/components/dropdown-menu" rel="noopener noreferrer"&gt;Dropdown Menu&lt;/a&gt;, &lt;a href="https://wingo-ui.com/components/dialog" rel="noopener noreferrer"&gt;Dialog&lt;/a&gt; and the date pickers. You write the component once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;use client&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ChevronDown&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;lucide-react&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Button&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/components/ui/button&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Popover&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;PopoverClose&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/components/ui/popover&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;PriceFilter&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;onApply&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;onApply&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Popover&lt;/span&gt;
      &lt;span class="na"&gt;trigger&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt; &lt;span class="na"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"soft"&lt;/span&gt; &lt;span class="na"&gt;rightIcon&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ChevronDown&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
          Price
        &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Price"&lt;/span&gt;
      &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Pick a range or type your own."&lt;/span&gt;
      &lt;span class="na"&gt;footer&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;PopoverClose&lt;/span&gt; &lt;span class="na"&gt;asChild&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt; &lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"sm"&lt;/span&gt; &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;onApply&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
            Apply
          &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;PopoverClose&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"text-muted-foreground"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Between $500 and $1,500&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Popover&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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 &lt;code&gt;responsive={false}&lt;/code&gt; when a panel has to stay anchored to a field.&lt;/p&gt;

&lt;p&gt;The sheet underneath is the free &lt;a href="https://wingo-ui.com/components/drawer" rel="noopener noreferrer"&gt;Drawer&lt;/a&gt;, 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 &lt;code&gt;desktop&lt;/code&gt; prop.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Live demo: Drawer.&lt;/strong&gt; 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). &lt;a href="https://wingo-ui.com/blog/mobile-first-react-components" rel="noopener noreferrer"&gt;Try it live on wingo-ui.com&lt;/a&gt; or open the &lt;a href="https://wingo-ui.com/components/drawer" rel="noopener noreferrer"&gt;Drawer docs&lt;/a&gt;. Install: &lt;code&gt;npx wingo-ui@latest add drawer&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Two posts go deeper on this rule. The &lt;a href="https://wingo-ui.com/blog/react-bottom-sheet-for-the-web" rel="noopener noreferrer"&gt;React bottom sheet guide&lt;/a&gt; covers snap points, drag to dismiss, nested sheets and the keyboard, and the &lt;a href="https://wingo-ui.com/blog/shadcn-responsive-dialog-on-mobile" rel="noopener noreferrer"&gt;shadcn responsive dialog tutorial&lt;/a&gt; builds the same card-to-sheet switch on stock shadcn/ui.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you switch between a sheet and a dialog without a hydration flash?
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;matchMedia&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;use-mobile&lt;/code&gt; hook that ships with shadcn/ui's sidebar &lt;a href="https://github.com/shadcn-ui/ui/blob/main/apps/v4/registry/new-york-v4/hooks/use-mobile.ts" rel="noopener noreferrer"&gt;starts as &lt;code&gt;undefined&lt;/code&gt;, returns &lt;code&gt;false&lt;/code&gt;, and flips in an effect&lt;/a&gt; (as of October 2026). Our free &lt;a href="https://wingo-ui.com/components/use-media-query" rel="noopener noreferrer"&gt;useMediaQuery&lt;/a&gt; uses the same 768px breakpoint, reads it through &lt;code&gt;useSyncExternalStore&lt;/code&gt; and makes the server value a parameter. This is the core of it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useCallback&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useSyncExternalStore&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;useMediaQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;serverFallback&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;subscribe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useCallback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;onChange&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;list&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;matchMedia&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;list&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;change&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onChange&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;list&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;removeEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;change&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onChange&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;useSyncExternalStore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;subscribe&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;matchMedia&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;// the server and the hydration render&lt;/span&gt;
    &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;serverFallback&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;useIsMobile&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;useMediaQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;(max-width: 767.98px)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both hooks say &lt;code&gt;false&lt;/code&gt; 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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// wrong: the server renders the table, phones swap to cards after hydration&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isMobile&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useIsMobile&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;isMobile&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;InvoiceCards&lt;/span&gt; &lt;span class="na"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;InvoiceTable&lt;/span&gt; &lt;span class="na"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// right: both are in the HTML and CSS picks one before the first paint&lt;/span&gt;
&lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;InvoiceTable&lt;/span&gt; &lt;span class="na"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"hidden md:table"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;InvoiceCards&lt;/span&gt; &lt;span class="na"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"md:hidden"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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 &lt;a href="https://wingo-ui.com/blog/react-use-mobile-hook-ssr" rel="noopener noreferrer"&gt;guide to an SSR-safe React use mobile hook&lt;/a&gt; tests three common versions of the hook through hydration and sorts which jobs belong to CSS.&lt;/p&gt;

&lt;h2&gt;
  
  
  How big should touch targets be?
&lt;/h2&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Source (as of October 2026)&lt;/th&gt;
&lt;th&gt;Size&lt;/th&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://www.w3.org/WAI/WCAG22/Understanding/target-size-minimum.html" rel="noopener noreferrer"&gt;WCAG 2.2, 2.5.8 Target Size (Minimum)&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;24 by 24 CSS px, or enough spacing&lt;/td&gt;
&lt;td&gt;Level AA&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://www.w3.org/WAI/WCAG22/Understanding/target-size-enhanced.html" rel="noopener noreferrer"&gt;WCAG 2.2, 2.5.5 Target Size (Enhanced)&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;44 by 44 CSS px&lt;/td&gt;
&lt;td&gt;Level AAA&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://developer.apple.com/design/human-interface-guidelines/accessibility" rel="noopener noreferrer"&gt;Apple Human Interface Guidelines&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;44 by 44 pt default on iOS, 28 by 28 pt minimum&lt;/td&gt;
&lt;td&gt;Guideline&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://developer.android.com/guide/topics/ui/accessibility/apps" rel="noopener noreferrer"&gt;Android accessibility guide&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;48 by 48 dp&lt;/td&gt;
&lt;td&gt;Recommendation&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;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 &lt;a href="https://tailwindcss.com/docs/hover-focus-and-other-states" rel="noopener noreferrer"&gt;&lt;code&gt;pointer-coarse&lt;/code&gt; variant&lt;/a&gt; that compiles to &lt;code&gt;@media (pointer: coarse)&lt;/code&gt;, 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.&lt;/p&gt;

&lt;p&gt;For a text button the size is &lt;code&gt;h-10 pointer-coarse:h-11&lt;/code&gt;: 40px with a mouse, 44px under a finger. An icon button that must stay small gets an invisible hit area instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Ellipsis&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;lucide-react&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// looks 32px, takes 44px taps on touch&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;MoreButton&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;
      &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"button"&lt;/span&gt;
      &lt;span class="na"&gt;aria-label&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"More actions"&lt;/span&gt;
      &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"relative grid size-8 touch-manipulation place-items-center [-webkit-tap-highlight-color:transparent] pointer-coarse:after:absolute pointer-coarse:after:-inset-1.5"&lt;/span&gt;
    &lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Ellipsis&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"size-4"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;::after&lt;/code&gt; trick adds 6px on every side of the 32px button without moving anything. &lt;code&gt;touch-manipulation&lt;/code&gt; 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 &lt;a href="https://wingo-ui.com/components/button" rel="noopener noreferrer"&gt;Button&lt;/a&gt; in Wingo UI is built this way: &lt;code&gt;md&lt;/code&gt; is 40px with a mouse and 44px on touch, &lt;code&gt;sm&lt;/code&gt; and &lt;code&gt;icon-sm&lt;/code&gt; stay 32px and get the invisible hit area, and &lt;code&gt;lg&lt;/code&gt; is 48px everywhere. The &lt;a href="https://wingo-ui.com/blog/minimum-touch-target-size-react-tailwind" rel="noopener noreferrer"&gt;minimum touch target size guide&lt;/a&gt; compares what WCAG, Apple and Android require and shows how to test the hit areas.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why do hover effects break on phones?
&lt;/h2&gt;

&lt;p&gt;Touch screens have no hover, so anything that only appears on hover does not exist on a phone. In Tailwind CSS v4 the &lt;code&gt;hover:&lt;/code&gt; variant &lt;a href="https://tailwindcss.com/docs/upgrade-guide" rel="noopener noreferrer"&gt;only applies when the primary input can hover&lt;/a&gt; (&lt;code&gt;@media (hover: hover)&lt;/code&gt;). 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.&lt;/p&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Ellipsis&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;lucide-react&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ClientRow&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;li&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"group/row flex h-12 items-center justify-between gap-3 px-4"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;
        &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"button"&lt;/span&gt;
        &lt;span class="na"&gt;aria-label&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;`Actions for &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"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"&lt;/span&gt;
      &lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Ellipsis&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"size-4"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;li&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Hover reveals it for a mouse, focus reveals it for a keyboard, and on a coarse pointer it is always there. The &lt;a href="https://wingo-ui.com/components/data-table" rel="noopener noreferrer"&gt;Data Table&lt;/a&gt; puts the same classes on the wrapper of its row menu trigger, plus &lt;code&gt;has-[[data-state=open]]:opacity-100&lt;/code&gt;: 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you keep fixed bars clear of the notch and the home indicator?
&lt;/h2&gt;

&lt;p&gt;Set &lt;code&gt;viewport-fit=cover&lt;/code&gt;, then pad every element pinned to a screen edge with &lt;code&gt;env(safe-area-inset-*)&lt;/code&gt;, with a minimum for devices that report zero. &lt;code&gt;cover&lt;/code&gt; scales the viewport to fill the whole display, notch and home indicator included, and &lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/meta/name/viewport" rel="noopener noreferrer"&gt;MDN highly recommends&lt;/a&gt; the safe area inset variables with it so important content stays visible.&lt;/p&gt;

&lt;p&gt;In the Next.js App Router the viewport meta tag comes from a &lt;code&gt;viewport&lt;/code&gt; export in the root layout:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/layout.tsx&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Viewport&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;next&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;viewport&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Viewport&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;device-width&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;initialScale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;viewportFit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cover&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then pad with an arbitrary value: &lt;code&gt;pb-[max(env(safe-area-inset-bottom),12px)]&lt;/code&gt;. A plain &lt;code&gt;pb-[env(safe-area-inset-bottom)]&lt;/code&gt; leaves buttons touching the screen edge on devices without a home indicator, where the inset is 0; &lt;code&gt;max()&lt;/code&gt; keeps a little air.&lt;/p&gt;

&lt;p&gt;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 &lt;a href="https://wingo-ui.com/components/bottom-tab-bar" rel="noopener noreferrer"&gt;Bottom Tab Bar&lt;/a&gt; pads the home indicator and writes its height to a &lt;code&gt;--bottom-bar-offset&lt;/code&gt; CSS variable. The &lt;a href="https://wingo-ui.com/components/sticky-cta-bar" rel="noopener noreferrer"&gt;Sticky CTA Bar&lt;/a&gt; sits at &lt;code&gt;bottom: var(--bottom-bar-offset)&lt;/code&gt; and pads only the part of the safe area the tab bar does not already cover, so there is no double gap:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;use client&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;House&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Search&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ShoppingBag&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;UserRound&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;lucide-react&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;BottomTabBar&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;BottomTabBarItem&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/components/ui/bottom-tab-bar&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;StickyCtaBar&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/components/ui/sticky-cta-bar&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tabs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;BottomTabBarItem&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Home&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;icon&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;House&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Search&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/search&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;icon&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Search&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Cart&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/cart&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;icon&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ShoppingBag&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;,&lt;/span&gt; &lt;span class="na"&gt;badge&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Account&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/account&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;icon&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;UserRound&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ProductChrome&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;onAddToCart&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;onAddToCart&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;StickyCtaBar&lt;/span&gt;
        &lt;span class="na"&gt;price&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;349&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;compareAtPrice&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;449&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"en-US"&lt;/span&gt;
        &lt;span class="na"&gt;actionLabel&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Add to cart"&lt;/span&gt;
        &lt;span class="na"&gt;tone&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"brand"&lt;/span&gt;
        &lt;span class="na"&gt;hideFrom&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"md"&lt;/span&gt;
        &lt;span class="na"&gt;onAction&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;onAddToCart&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;BottomTabBar&lt;/span&gt; &lt;span class="na"&gt;items&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;tabs&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"md:hidden"&lt;/span&gt; &lt;span class="na"&gt;hideOnScroll&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tab bar takes 3 to 5 tabs, each far larger than 44px. With &lt;code&gt;hideOnScroll&lt;/code&gt; 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 &lt;code&gt;onAction&lt;/code&gt; promise runs without changing its width. The &lt;a href="https://wingo-ui.com/blog/tailwind-safe-area-insets" rel="noopener noreferrer"&gt;Tailwind safe area insets tutorial&lt;/a&gt; covers the utilities, a FAB stacked above the tab bar and where toasts go, and the &lt;a href="https://wingo-ui.com/blog/react-bottom-navigation-bar" rel="noopener noreferrer"&gt;React bottom navigation bar guide&lt;/a&gt; covers badges, the center action and when a sidebar fits better.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Live demo: Bottom Tab Bar.&lt;/strong&gt; 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). &lt;a href="https://wingo-ui.com/blog/mobile-first-react-components" rel="noopener noreferrer"&gt;Try it live on wingo-ui.com&lt;/a&gt; or open the &lt;a href="https://wingo-ui.com/components/bottom-tab-bar" rel="noopener noreferrer"&gt;Bottom Tab Bar docs&lt;/a&gt;. Install: &lt;code&gt;npx wingo-ui@latest add bottom-tab-bar&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should you use 100vh, dvh or svh on mobile?
&lt;/h2&gt;

&lt;p&gt;Use &lt;code&gt;dvh&lt;/code&gt; for app shells and sheets, &lt;code&gt;svh&lt;/code&gt; for blocks that must not change height while scrolling, and never &lt;code&gt;vh&lt;/code&gt;. &lt;a href="https://developer.mozilla.org/en-US/docs/Web/CSS/length" rel="noopener noreferrer"&gt;MDN defines&lt;/a&gt; &lt;code&gt;vh&lt;/code&gt; as equal to &lt;code&gt;lvh&lt;/code&gt;, the large viewport measured with the browser toolbars retracted. While the address bar is showing, &lt;code&gt;100vh&lt;/code&gt; (Tailwind's &lt;code&gt;h-screen&lt;/code&gt;) is taller than the visible area, and the bottom of your layout, often the button that matters, sits under the toolbar.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;dvh&lt;/code&gt; 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 &lt;code&gt;dvh&lt;/code&gt; may twitch; &lt;code&gt;svh&lt;/code&gt; is the stable choice there. Tailwind CSS v4 ships &lt;code&gt;h-dvh&lt;/code&gt;, &lt;code&gt;min-h-dvh&lt;/code&gt;, &lt;code&gt;h-svh&lt;/code&gt; and &lt;code&gt;h-lvh&lt;/code&gt;. In Wingo UI the &lt;a href="https://wingo-ui.com/components/app-shell" rel="noopener noreferrer"&gt;App Shell&lt;/a&gt; uses &lt;code&gt;min-h-dvh&lt;/code&gt; and bottom sheets cap at &lt;code&gt;max-h-[92dvh]&lt;/code&gt;, so a tall sheet keeps a strip of the page visible above it whatever the toolbars do. The &lt;a href="https://wingo-ui.com/blog/100vh-mobile-dvh-tailwind" rel="noopener noreferrer"&gt;100vh mobile fix&lt;/a&gt; compares the three units in detail and builds both kinds of full-height shell.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happens to bottom bars when the on-screen keyboard opens?
&lt;/h2&gt;

&lt;p&gt;They end up behind the keyboard unless you move them. Since &lt;a href="https://developer.chrome.com/blog/viewport-resize-behavior" rel="noopener noreferrer"&gt;Chrome 108 on Android&lt;/a&gt;, the keyboard resizes only the visual viewport, which the Chrome team describes as matching Safari on iOS. An element with &lt;code&gt;position: fixed; bottom: 0&lt;/code&gt; is placed against the layout viewport, which still reaches the bottom of the screen, behind the keyboard.&lt;/p&gt;

&lt;p&gt;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, &lt;code&gt;keyboard="hide"&lt;/code&gt;). Or lift it by the height the keyboard covers, which suits a chat composer (&lt;code&gt;keyboard="lift"&lt;/code&gt;). The lift comes from the visual viewport: the covered height is &lt;code&gt;innerHeight - visualViewport.height - visualViewport.offsetTop&lt;/code&gt; while a text field has focus. The &lt;a href="https://wingo-ui.com/components/use-keyboard-inset" rel="noopener noreferrer"&gt;useKeyboardInset&lt;/a&gt; hook (Pro) measures it at most once per frame and exposes it as a motion value, so the bar moves without a re-render:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;use client&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;motion&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useTransform&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;motion/react&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useKeyboardInset&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/hooks/use-keyboard-inset&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Composer&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;inset&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useKeyboardInset&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useTransform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;inset&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;motion&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nt"&gt;form&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"fixed inset-x-0 bottom-0 bg-background px-4 pt-2 pb-[max(env(safe-area-inset-bottom),12px)]"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;input&lt;/span&gt;
        &lt;span class="na"&gt;aria-label&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Message"&lt;/span&gt;
        &lt;span class="na"&gt;inputMode&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"text"&lt;/span&gt;
        &lt;span class="na"&gt;enterKeyHint&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"send"&lt;/span&gt;
        &lt;span class="na"&gt;autoComplete&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"off"&lt;/span&gt;
        &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"h-11 w-full rounded-full bg-foreground/5 px-4 text-base md:text-sm"&lt;/span&gt;
      &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;motion&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nt"&gt;form&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two more keyboard rules. Give text inputs 16px text on phones (&lt;code&gt;text-base md:text-sm&lt;/code&gt;): iOS Safari zooms the page into any focused field with smaller text and does not zoom back out. And set &lt;code&gt;inputMode&lt;/code&gt;, &lt;code&gt;enterKeyHint&lt;/code&gt; and &lt;code&gt;autoComplete&lt;/code&gt; on every field, so the phone shows the right keyboard, the right return key label and the right autofill.&lt;/p&gt;

&lt;h2&gt;
  
  
  How should a data table work on a phone?
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://wingo-ui.com/components/data-table" rel="noopener noreferrer"&gt;Data Table&lt;/a&gt; does this by default (&lt;code&gt;mobileLayout="cards"&lt;/code&gt;). Each column can say where it goes on the card with a &lt;code&gt;mobile&lt;/code&gt; 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):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;use client&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Pencil&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;lucide-react&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;DataTable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;createColumns&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/components/ui/data-table&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Invoice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;client&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;city&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;col&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;createColumns&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Invoice&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;columns&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;col&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
  &lt;span class="nx"&gt;col&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;accessor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;client&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;header&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Client&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;mobile&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;title&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="nx"&gt;col&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;accessor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;city&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;header&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;City&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;mobile&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;subtitle&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="nx"&gt;col&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;accessor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;status&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;header&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Status&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;badge&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;badge&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Paid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;tone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;success&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="na"&gt;Overdue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;tone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;danger&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="na"&gt;Draft&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;tone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;neutral&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;mobile&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;meta&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="nx"&gt;col&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;accessor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;amount&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;header&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Amount&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;currency&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="nx"&gt;col&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;accessor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;header&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Invoice&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;mobile&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hidden&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Invoices&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;invoices&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;invoices&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Invoice&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;DataTable&lt;/span&gt;
      &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;invoices&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;columns&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;columns&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;caption&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Invoices"&lt;/span&gt;
      &lt;span class="na"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"en-US"&lt;/span&gt;
      &lt;span class="na"&gt;selectionMode&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"multiple"&lt;/span&gt;
      &lt;span class="na"&gt;rowActions&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Edit&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;icon&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Pencil&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;,&lt;/span&gt; &lt;span class="na"&gt;onSelect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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 &lt;a href="https://wingo-ui.com/blog/tanstack-table-v9-data-table" rel="noopener noreferrer"&gt;TanStack Table v9 data table tutorial&lt;/a&gt; builds filters, bulk actions and server pagination on TanStack directly, along with the phone layout.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Live demo: Data Table.&lt;/strong&gt; 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). &lt;a href="https://wingo-ui.com/blog/mobile-first-react-components" rel="noopener noreferrer"&gt;Try it live on wingo-ui.com&lt;/a&gt; or open the &lt;a href="https://wingo-ui.com/components/data-table" rel="noopener noreferrer"&gt;Data Table docs&lt;/a&gt;. Install: &lt;code&gt;npx wingo-ui@latest add data-table&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;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 &lt;a href="https://wingo-ui.com/blog/responsive-table-mobile-cards" rel="noopener noreferrer"&gt;responsive table mobile guide&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which gestures belong in a mobile web app?
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Always a button path.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;touch-action&lt;/code&gt; on draggable surfaces.&lt;/strong&gt; &lt;code&gt;touch-pan-y&lt;/code&gt; on something you swipe sideways keeps vertical scrolling native; &lt;code&gt;touch-none&lt;/code&gt; is for surfaces you drag freely, like a signature pad or a slider thumb.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;overscroll-behavior: contain&lt;/code&gt; on scroll containers.&lt;/strong&gt; It stops a scroll inside a sheet from chaining into the page, and keeps the browser's own pull to refresh from fighting yours.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Motion values, not state.&lt;/strong&gt; A drag that calls &lt;code&gt;setState&lt;/code&gt; on every pointer move re-renders on every frame. Write the offset to a motion value and only commit state when the gesture ends.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Sortable lists and boards are where these rules collide with native scrolling; the &lt;a href="https://wingo-ui.com/blog/dnd-kit-mobile-touch-drag" rel="noopener noreferrer"&gt;dnd kit mobile tutorial&lt;/a&gt; sets up touch sensors that let a swipe still scroll the page.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://wingo-ui.com/components/pull-to-refresh" rel="noopener noreferrer"&gt;Pull To Refresh&lt;/a&gt; shows where the rules bend. It sets &lt;code&gt;overscroll-behavior-y: contain&lt;/code&gt; on its scroller and moves the content with a motion value under rubberband resistance. It cannot use &lt;code&gt;touch-action&lt;/code&gt;, because the list it wraps must keep scrolling natively, so it listens to &lt;code&gt;touchmove&lt;/code&gt; with &lt;code&gt;passive: false&lt;/code&gt; 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 &lt;a href="https://wingo-ui.com/blog/react-pull-to-refresh" rel="noopener noreferrer"&gt;React pull to refresh guide&lt;/a&gt; covers the indicator styles, accessibility and long virtualized lists):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;use client&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;ReactNode&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;RotateCw&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;lucide-react&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Button&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/components/ui/button&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;PullToRefresh&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;PullToRefreshApi&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/components/ui/pull-to-refresh&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Feed&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;reload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;children&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;reload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;children&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ReactNode&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setApi&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;PullToRefreshApi&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"flex h-dvh flex-col"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;header&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"flex h-14 items-center justify-end px-4"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt; &lt;span class="na"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"ghost"&lt;/span&gt; &lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"icon"&lt;/span&gt; &lt;span class="na"&gt;radius&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"full"&lt;/span&gt; &lt;span class="na"&gt;aria-label&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Reload"&lt;/span&gt; &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;refresh&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;RotateCw&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;header&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;PullToRefresh&lt;/span&gt; &lt;span class="na"&gt;scroll&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"self"&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"min-h-0 flex-1"&lt;/span&gt; &lt;span class="na"&gt;setApi&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;setApi&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;onRefresh&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;reload&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;PullToRefresh&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Hold to confirm is the gesture for actions that deserve more than a tap and less than a dialog, like deleting a client. The &lt;a href="https://wingo-ui.com/components/haptic-button" rel="noopener noreferrer"&gt;Haptic Button&lt;/a&gt; in &lt;code&gt;mode="hold"&lt;/code&gt; fills while you hold a finger, the mouse button, Space or Enter, and runs &lt;code&gt;onConfirm&lt;/code&gt; 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.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;use client&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Trash2&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;lucide-react&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;HapticButton&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/components/ui/haptic-button&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;DeleteClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;onDelete&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;onDelete&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;HapticButton&lt;/span&gt; &lt;span class="na"&gt;tone&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"danger"&lt;/span&gt; &lt;span class="na"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"soft"&lt;/span&gt; &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"hold"&lt;/span&gt; &lt;span class="na"&gt;fullWidth&lt;/span&gt; &lt;span class="na"&gt;leftIcon&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Trash2&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;onConfirm&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;onDelete&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      Hold to delete
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;HapticButton&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On Android the tick uses &lt;code&gt;navigator.vibrate&lt;/code&gt;. Safari on iPhone has no Vibration API, so the haptics helper falls back to clicking a hidden native switch (&lt;code&gt;&amp;lt;input type="checkbox" switch&amp;gt;&lt;/code&gt;). 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 &lt;a href="https://wingo-ui.com/blog/web-vibration-api-haptic-feedback" rel="noopener noreferrer"&gt;Web Vibration API guide&lt;/a&gt; covers what still ticks on an iPhone and how to let people turn haptics off.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does shadcn/ui handle mobile?
&lt;/h2&gt;

&lt;p&gt;Partly. As of October 2026 the shadcn mobile story is one component and one pattern. The &lt;a href="https://ui.shadcn.com/docs/components/drawer" rel="noopener noreferrer"&gt;Drawer docs&lt;/a&gt; come in a Base UI version, which now uses Base UI's drawer instead of Vaul, and a Radix version, which is still &lt;a href="https://ui.shadcn.com/docs/components/radix/drawer" rel="noopener noreferrer"&gt;built on Vaul&lt;/a&gt;. Both include a Responsive section that renders a &lt;code&gt;Dialog&lt;/code&gt; on desktop and a &lt;code&gt;Drawer&lt;/code&gt; on mobile; the example decides with &lt;code&gt;useMediaQuery("(min-width: 768px)")&lt;/code&gt; and shares one form between both branches. The &lt;a href="https://ui.shadcn.com/docs/components/popover" rel="noopener noreferrer"&gt;Popover docs&lt;/a&gt; describe no mobile behavior, and Select and Dropdown Menu stay floating panels at every width unless you write a wrapper for each.&lt;/p&gt;

&lt;p&gt;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 &lt;a href="https://wingo-ui.com/blog/best-react-component-library-for-mobile" rel="noopener noreferrer"&gt;comparison of the best React component libraries for mobile&lt;/a&gt; checks shadcn/ui and eight others at 390px.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which components does a React mobile web app need?
&lt;/h2&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Job on a phone&lt;/th&gt;
&lt;th&gt;Access&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://wingo-ui.com/components/drawer" rel="noopener noreferrer"&gt;Drawer&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;The bottom sheet: drag to dismiss, snap points, nesting&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Popover, Select, Dropdown Menu, Dialog&lt;/td&gt;
&lt;td&gt;Open as bottom sheets below 768px&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://wingo-ui.com/components/segmented-control" rel="noopener noreferrer"&gt;Segmented Control&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;A few options on a thumb you can drag&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://wingo-ui.com/components/bottom-tab-bar" rel="noopener noreferrer"&gt;Bottom Tab Bar&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;3 to 5 primary destinations at the thumb&lt;/td&gt;
&lt;td&gt;Pro&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://wingo-ui.com/components/sticky-cta-bar" rel="noopener noreferrer"&gt;Sticky CTA Bar&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Price and the main action, above the tab bar&lt;/td&gt;
&lt;td&gt;Pro&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://wingo-ui.com/components/pull-to-refresh" rel="noopener noreferrer"&gt;Pull To Refresh&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Reload a list with a pull, or a button&lt;/td&gt;
&lt;td&gt;Pro&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://wingo-ui.com/components/list" rel="noopener noreferrer"&gt;List&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Grouped rows with swipe actions and drag to reorder&lt;/td&gt;
&lt;td&gt;Pro&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://wingo-ui.com/components/data-table" rel="noopener noreferrer"&gt;Data Table&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Rows as cards, long press to select&lt;/td&gt;
&lt;td&gt;Pro&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://wingo-ui.com/components/haptic-button" rel="noopener noreferrer"&gt;Haptic Button&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Press feedback, haptics and hold to confirm&lt;/td&gt;
&lt;td&gt;Pro&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://wingo-ui.com/components/large-title-header" rel="noopener noreferrer"&gt;Large Title Header&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;An iOS large title that collapses into a bar&lt;/td&gt;
&lt;td&gt;Pro&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://wingo-ui.com/components/action-sheet" rel="noopener noreferrer"&gt;Action Sheet&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;A short list of actions about one thing&lt;/td&gt;
&lt;td&gt;Pro&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://wingo-ui.com/components/picker-wheel" rel="noopener noreferrer"&gt;Picker Wheel&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;iOS wheels for times and dates&lt;/td&gt;
&lt;td&gt;Pro&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The whole &lt;a href="https://wingo-ui.com/components/category/mobile" rel="noopener noreferrer"&gt;Mobile category&lt;/a&gt; has 15 components, all built to these rules, with reduced motion support and designed light and dark themes.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you test mobile-first React components?
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;pointer-coarse&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;For automated checks, Playwright can emulate a touch phone per test file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;test&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@playwright/test&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;test&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;viewport&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;390&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;844&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="na"&gt;hasTouch&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;isMobile&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;filters open as a bottom sheet on a phone&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/listings&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;button&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Filters&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;tap&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;dialog&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Filters&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;})).&lt;/span&gt;&lt;span class="nf"&gt;toBeVisible&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which guides go deeper on each rule?
&lt;/h2&gt;

&lt;p&gt;Each of these posts in &lt;a href="https://wingo-ui.com/blog/category/mobile-ui" rel="noopener noreferrer"&gt;Mobile UI in React&lt;/a&gt; takes one rule or one component further:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://wingo-ui.com/blog/shadcn-responsive-dialog-on-mobile" rel="noopener noreferrer"&gt;Shadcn responsive dialog&lt;/a&gt;: a centered card on desktop and a bottom sheet on phones, with the content written once.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://wingo-ui.com/blog/react-bottom-sheet-for-the-web" rel="noopener noreferrer"&gt;React bottom sheet for the web&lt;/a&gt;: snap points, drag to dismiss, nested sheets and the keyboard on vaul.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://wingo-ui.com/blog/minimum-touch-target-size-react-tailwind" rel="noopener noreferrer"&gt;Minimum touch target size&lt;/a&gt;: the WCAG, iOS and Android numbers, and 44px on touch in Tailwind without a bloated desktop.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://wingo-ui.com/blog/tailwind-safe-area-insets" rel="noopener noreferrer"&gt;Tailwind safe area insets&lt;/a&gt;: bottom bars, FABs and toasts clear of the notch and the home indicator.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://wingo-ui.com/blog/100vh-mobile-dvh-tailwind" rel="noopener noreferrer"&gt;100vh on mobile&lt;/a&gt;: when to use dvh, svh or lvh, and full-height shells that do not jump.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://wingo-ui.com/blog/react-bottom-navigation-bar" rel="noopener noreferrer"&gt;React bottom navigation bar&lt;/a&gt;: a tab bar with badges, a center action and hide on scroll.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://wingo-ui.com/blog/react-pull-to-refresh" rel="noopener noreferrer"&gt;React pull to refresh&lt;/a&gt;: a custom refresh in a PWA that does not fight the browser's own.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://wingo-ui.com/blog/web-vibration-api-haptic-feedback" rel="noopener noreferrer"&gt;Web Vibration API&lt;/a&gt;: the haptic feedback a page can trigger on Android and iPhone.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://wingo-ui.com/blog/react-pwa-install-prompt" rel="noopener noreferrer"&gt;React PWA install prompt&lt;/a&gt;: an install prompt for Chrome, Edge and iPhone, and when not to ask.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://wingo-ui.com/blog/responsive-table-mobile-cards" rel="noopener noreferrer"&gt;Responsive tables on mobile&lt;/a&gt;: when rows become cards, which columns survive, and sorting and bulk actions without headers.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://wingo-ui.com/blog/react-segmented-control" rel="noopener noreferrer"&gt;React segmented control&lt;/a&gt;: the iOS thumb you can drag, radio semantics, and when tabs or a toggle group fit better.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://wingo-ui.com/blog/react-wheel-picker" rel="noopener noreferrer"&gt;React wheel picker&lt;/a&gt;: iOS wheels for times and dates on a native snapping scroller, with a typed field on desktop.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where should you start?
&lt;/h2&gt;

&lt;p&gt;Start with the overlays, because they are the most visible mobile failure and the cheapest to fix. Install the free Popover with &lt;code&gt;npx wingo-ui@latest add popover&lt;/code&gt; (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 &lt;a href="https://wingo-ui.com/pricing" rel="noopener noreferrer"&gt;Wingo UI Pro&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Components in this post
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://wingo-ui.com/components/drawer" rel="noopener noreferrer"&gt;Drawer&lt;/a&gt; (Free): The library's bottom sheet on vaul: drag to dismiss, snap points, nesting, and a side panel or dialog on desktop. Install: &lt;code&gt;npx wingo-ui@latest add drawer&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://wingo-ui.com/components/popover" rel="noopener noreferrer"&gt;Popover&lt;/a&gt; (Free): Rich floating content anchored to a trigger, like a quick filter, form or share panel, that becomes a bottom sheet on phones. Install: &lt;code&gt;npx wingo-ui@latest add popover&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://wingo-ui.com/components/bottom-tab-bar" rel="noopener noreferrer"&gt;Bottom Tab Bar&lt;/a&gt; (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: &lt;code&gt;npx wingo-ui@latest add bottom-tab-bar&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://wingo-ui.com/components/pull-to-refresh" rel="noopener noreferrer"&gt;Pull To Refresh&lt;/a&gt; (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: &lt;code&gt;npx wingo-ui@latest add pull-to-refresh&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://wingo-ui.com/components/sticky-cta-bar" rel="noopener noreferrer"&gt;Sticky CTA Bar&lt;/a&gt; (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: &lt;code&gt;npx wingo-ui@latest add sticky-cta-bar&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://wingo-ui.com/components/data-table" rel="noopener noreferrer"&gt;Data Table&lt;/a&gt; (Pro): The admin table on TanStack Table v9: search, filters, sorting, bulk actions, pinning, resizing, virtualization and cards on phones. Install: &lt;code&gt;npx wingo-ui@latest add data-table&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://wingo-ui.com/components/haptic-button" rel="noopener noreferrer"&gt;Haptic Button&lt;/a&gt; (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: &lt;code&gt;npx wingo-ui@latest add haptic-button&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What is a mobile-first React component?
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  How big should buttons be in a mobile web app?
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I use a useIsMobile hook or CSS media queries?
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does shadcn/ui have a bottom sheet for mobile?
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why do hover effects not work on phones?
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which Wingo UI mobile components are free?
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article was originally published on the Wingo UI blog: &lt;a href="https://wingo-ui.com/blog/mobile-first-react-components" rel="noopener noreferrer"&gt;Mobile-First React Components: 9 Rules With Code&lt;/a&gt;. The original has the live, interactive demos and is kept up to date.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>react</category>
      <category>mobile</category>
      <category>webdev</category>
      <category>frontend</category>
    </item>
  </channel>
</rss>
