ui-stack-tailwind
Building with Tailwind CSS v4 (and v3) without the Tailwind-default look: setup per framework (Vite, Next.js, Astro, Laravel, monorepos), @theme tokens in OKLCH named by role, dark mode with @custom-variant, the layout system as tokens (one container, section spacing, a type scale, stretched rows) and how to fix each preview layout line with classes, state, aria, data, group, peer, has, motion and container-query variants, component recipes (button, field, card, navigation, table, dialog) with every state, motion with --animate-* and starting:, cn with tailwind-merge, cva, class order, @utility, arbitrary values, typography, v3 to v4 migration, and the generated look. Read when the project uses Tailwind.
- License 许可
- Apache-2.0
- 所属仓库
- enowdev/enowxcli
- 最近更新
- 2026-10-06
- Stars
- ★ 67
- Forks
- 21
01 前言 Front Matter
- name
- ui-stack-tailwind
- description
- Building with Tailwind CSS v4 (and v3) without the Tailwind-default look: setup per framework (Vite, Next.js, Astro, Laravel, monorepos), @theme tokens in OKLCH named by role, dark mode with @custom-variant, the layout system as tokens (one container, section spacing, a type scale, stretched rows) and how to fix each preview layout line with classes, state, aria, data, group, peer, has, motion and container-query variants, component recipes (button, field, card, navigation, table, dialog) with every state, motion with --animate-* and starting:, cn with tailwind-merge, cva, class order, @utility, arbitrary values, typography, v3 to v4 migration, and the generated look. Read when the project uses Tailwind.
03 SKILL.md 正文
Tailwind CSS
Tailwind makes the default look very cheap: bg-gradient-to-r from-purple-500 to-pink-500, rounded-2xl shadow-2xl, backdrop-blur on
every card, slate greys with an indigo accent. Use it to apply the product's
decisions, not the framework's: tokens in the theme, utilities named by
role, a state on every control, components instead of copied class strings.
Version 4 (4.3 in 2026) is current; v3 notes are in section 11.
1. Which version, and the setup
- v4: the CSS starts with
@import "tailwindcss";and the build uses@tailwindcss/vite,@tailwindcss/postcssor@tailwindcss/cli. There is nocontentlist: sources are found automatically (ignoring what.gitignoreignores), and@source "../packages/ui"adds a path. - v3: a
tailwind.config.jswithcontent, and@tailwind base; @tailwind components; @tailwind utilities;in the CSS. - v4 needs Safari 16.4+, Chrome 111+ and Firefox 128+; a product that must run on older browsers stays on v3.4.
| Stack | Install | Wire it |
|---|---|---|
| Vite (React, Vue, Svelte, plain) | npm i tailwindcss @tailwindcss/vite |
plugins: [tailwindcss()] in vite.config.ts, @import "tailwindcss"; in the main CSS |
| Next.js | npm i tailwindcss @tailwindcss/postcss postcss |
postcss.config.mjs: export default { plugins: { "@tailwindcss/postcss": {} } }; the import in app/globals.css |
| Astro | npx astro add tailwind (the Vite plugin) |
the import in src/styles/global.css, imported by the layout |
| Laravel | npm i tailwindcss @tailwindcss/vite |
the Vite plugin beside laravel-vite-plugin; @source "../../resources/views"; when Blade files are not found |
| No build | npx @tailwindcss/cli -i src/in.css -o dist/out.css --watch |
the built file linked in HTML; never the CDN script in production |
- Sources:
@import "tailwindcss" source("../src");sets where classes are found;@source "../packages/ui/src";adds a package in a monorepo;@source not "../legacy";leaves a folder out. A class built from strings (`bg-${tone}`) is never found: map to full class names ({ ok: "bg-ok", warn: "bg-warn" }[tone]).
2. Theme first: tokens named by role
The palette, fonts, radii and shadows live in the theme, named by role:
bg-surface, text-muted, border-line, bg-accent, not bg-purple-600
scattered through the app.
@import "tailwindcss";
@theme {
--color-*: initial; /* the product's colours only, no default palette */
--color-paper: oklch(0.98 0.006 85); /* values from DESIGN.md */
--color-surface: oklch(1 0 0);
--color-surface-2: oklch(0.955 0.008 85);
--color-ink: oklch(0.21 0.012 85);
--color-muted: oklch(0.49 0.012 85);
--color-line: oklch(0.9 0.008 85);
--color-accent: oklch(0.5 0.14 40);
--color-on-accent: oklch(0.99 0.004 85);
--color-danger: oklch(0.52 0.19 27);
--font-sans: "Public Sans", ui-sans-serif, system-ui, sans-serif;
--font-display: "Newsreader", ui-serif, Georgia, serif;
--text-display: clamp(2rem, 1.4rem + 2.6vw, 3.0625rem);
--text-display--line-height: 1.1;
--radius-sm: 4px;
--radius-md: 8px;
--shadow-overlay: 0 8px 24px oklch(0.2 0.02 85 / 0.14);
--ease-out-soft: cubic-bezier(0.22, 0.61, 0.36, 1);
}
- Each variable makes its utilities:
--color-accentgivesbg-accent,text-accent,border-accent;--text-displaygivestext-display;--ease-out-softgivesease-out-soft. They are also CSS variables at:root, for plain CSS (var(--color-accent)) and scripts. --color-*: initialdrops the default palette, sobg-purple-600stops compiling; leave it out when a library in the project uses those colours.- Colours in OKLCH: the steps of a role (hover, border, dark) keep the hue and move the lightness, so they stay one family.
- The spacing scale is
--spacing: 0.25rem(a 4px base, whatuiasks for); keep it. Breakpoints are--breakpoint-*: add one where the content breaks, not per device. @theme inlinewhen a token's value is another variable (a next/font variable, shadcn's--color-primary: var(--primary)): the utility then reads that variable where it is used, not a value resolved at the root.
3. Dark mode and themes
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
@layer base {
[data-theme="dark"] {
color-scheme: dark;
--color-paper: oklch(0.16 0.006 85);
--color-surface: oklch(0.2 0.007 85);
--color-surface-2: oklch(0.24 0.008 85);
--color-ink: oklch(0.93 0.006 85);
--color-muted: oklch(0.7 0.01 85);
--color-line: oklch(0.3 0.008 85);
--color-accent: oklch(0.72 0.15 45);
--color-on-accent: oklch(0.18 0.02 45);
}
}
- Dark mode by the class or data-attribute strategy, through the variables:
utilities read them, so redefining them under the switch changes every
bg-paperandtext-inkat once, and most elements need nodark:class at all.dark:stays for what is not a colour token (another image, a shadow that goes away). - Without the custom variant,
dark:followsprefers-color-schemeonly, and a toggle cannot win. The script in<head>setsdata-themefrom the saved choice or the system before the first paint (ui-themes).
4. The layout system as tokens
A page built section by section drifts in Tailwind as much as anywhere: one
section max-w-3xl mx-auto, the next max-w-6xl px-8, gaps of py-4 here
and py-32 there, eleven text sizes. The measures in ui-layout go into the
theme once, and every section uses the same few classes:
@theme {
--container-page: 72rem; /* max-w-page: the one content width */
--container-measure: 65ch; /* max-w-measure: running text */
--spacing-gutter: clamp(1rem, 4vw, 2rem); /* px-gutter */
--spacing-section: clamp(3rem, 8vw, 6rem); /* py-section: between sections */
--text-*: initial; /* the product's scale only */
--text-sm: 0.875rem; --text-sm--line-height: 1.5;
--text-base: 1rem; --text-base--line-height: 1.6;
--text-lg: 1.25rem; --text-lg--line-height: 1.5;
--text-xl: 1.563rem; --text-xl--line-height: 1.3;
--text-2xl: 1.953rem; --text-2xl--line-height: 1.2;
--text-display: clamp(2.441rem, 1.8rem + 3vw, 3.815rem); --text-display--line-height: 1.1;
}
@utility page { width: 100%; max-width: var(--container-page); margin-inline: auto; padding-inline: var(--spacing-gutter); }
<section class="py-section">
<div class="page">
<h2 class="text-2xl font-display">Selected work</h2>
<p class="mt-3 max-w-measure text-muted">…</p>
<ul class="mt-8 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">…</ul>
</div>
</section>
- Every section's content sits in
page: one left edge, one width. A narrower column ismax-w-measureinside it, left-aligned, never a secondmx-autobox. - Text sizes come from the scale (
text-smtotext-2xl, andtext-displayfrom section 2); with--text-*: initialthe other defaults stop compiling, so a straytext-5xldoes nothing and shows at once, and atext-[15px]is a finding in review. The page title istext-display, at least twice the body. - Spacing from the 4px scale:
gap-2/gap-3inside a component,gap-6between items,mt-8/mt-12between groups,py-sectionbetween sections. A heading sits closer to what follows (mt-3) than to what came before. - Rows of cards share a height: a grid stretches its items by default
(
grid auto-rows-frwhen every row should match), and each card isflex flex-colwith its actionmt-auto. - The first section clears the header:
pt-section, or the header's height when it is sticky (scroll-mt-16on anchored sections too).
The preview layout lines, in classes
| Line | Fix |
|---|---|
| sections start at N left edges / content widths differ | Wrap each section's content in page; remove its own max-w-*, mx-auto, px-* |
| blocks a few pixels off the edge | A pl-*, ml-*, border or -mx-* on one block: remove it, or move it inside |
| the first content sits 0px from the top | pt-section on the first section, or header padding |
| sections run into each other | py-section on every section |
| elements overlap | A fixed w-*/h-* or negative margin at this width: shrink-0 on the media, flex-col below sm: |
| still invisible after scrolling | An opacity-0 start that a class never removed: start visible, animate with motion-safe: and starting: (section 8) |
| N font sizes / weak h1 | Only the scale; text-display for the title, text-2xl for section heads |
| body text is 13px | text-base (16px) for body copy, text-sm only for secondary text |
| paragraph lines run long | max-w-measure on running text |
| boxes side by side with different heights | items-stretch (the grid default), flex flex-col in each box, mt-auto on its action |
| N different text colours | text-ink, text-muted, text-accent and state colours only |
5. Variants: states, structure, containers
States on every control: hover:, focus-visible:, disabled:, and the
ARIA and data attributes the component already sets:
<button class="inline-flex h-10 items-center gap-2 rounded-md bg-accent px-4 font-medium text-on-accent
hover:bg-accent/90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent
disabled:cursor-not-allowed disabled:opacity-50 aria-busy:cursor-progress">Save changes</button>
<input class="h-10 rounded-md border border-line bg-surface px-3 aria-invalid:border-danger user-invalid:border-danger">
- Never
outline-nonewithout afocus-visiblestyle. In v4 the oldoutline-noneisoutline-hidden(it keeps an outline in forced-colours mode); either needs the visible replacement. aria-expanded:,aria-selected:,data-[state=open]:(ordata-open:for a present attribute) style the state the component exposes; no second class to keep in sync. v3 writesaria-[invalid=true]:.- Parents and siblings:
groupwithgroup-hover:orgroup-focus-within:,peerwithpeer-checked:,has-[input:checked]:for a parent that changes with its content,not-last:and*:for children. motion-safe:on animations and transform transitions;motion-reduce:transition-nonewhere a library adds its own (motion-stacks).- Mobile-first: unprefixed for the phone,
sm:md:lg:as it widens,max-md:for below. Rearrange (flex-col md:flex-row,order-*), do not duplicate markup behindhidden md:block. - Container queries for a component placed at different widths:
@containeron the parent,@md:grid-cols-2on the child; named ones with@container/cardand@md/card:. - Page container:
mx-auto max-w-6xl px-4 sm:px-6, textmax-w-prose; inside an app shell, nomx-auto(ui-layout).
6. Recipes, with every state
<!-- Field: the label names it, the hint and the error describe it -->
<div class="grid gap-1.5">
<label for="email" class="text-sm font-medium text-ink">Email</label>
<input id="email" type="email" aria-describedby="email-hint email-error" aria-invalid="true"
class="h-10 rounded-md border border-line bg-surface px-3 text-base text-ink placeholder:text-muted
focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-accent
aria-invalid:border-danger disabled:bg-surface-2 disabled:text-muted">
<p id="email-hint" class="text-sm text-muted">We send the receipt here.</p>
<p id="email-error" class="text-sm text-danger">Enter an address like name@example.com.</p>
</div>
<!-- Card: the whole card is the link; the title is the name, the action sits at the bottom -->
<article class="group relative flex flex-col rounded-md border border-line bg-surface p-5
hover:border-ink/30 has-focus-visible:outline-2 has-focus-visible:outline-accent">
<h3 class="text-lg font-medium"><a href="/work/ledger" class="after:absolute after:inset-0 focus:outline-none">Ledger</a></h3>
<p class="mt-2 text-muted">…</p>
<span class="mt-auto pt-4 text-sm text-accent group-hover:underline">Read the case</span>
</article>
<!-- Navigation: the current page is marked for everyone, not by colour alone -->
<a href="/work" aria-current="page"
class="rounded-sm px-2 py-1 text-muted hover:text-ink aria-[current=page]:font-medium
aria-[current=page]:text-ink aria-[current=page]:underline underline-offset-4">Work</a>
<!-- Table: scrolls in its own box on a phone; numbers right-aligned in tabular figures -->
<div class="overflow-x-auto rounded-md border border-line">
<table class="w-full text-sm">
<thead class="bg-surface-2 text-left text-muted"><tr><th scope="col" class="px-4 py-2 font-medium">Invoice</th>
<th scope="col" class="px-4 py-2 text-right font-medium">Amount</th></tr></thead>
<tbody class="divide-y divide-line"><tr class="hover:bg-surface-2">
<td class="px-4 py-3">INV-0042</td><td class="px-4 py-3 text-right tabular-nums">Rp 1.250.000</td></tr></tbody>
</table>
</div>
<!-- Dialog: the native element, its backdrop, and an entry that respects reduced motion -->
<dialog class="m-auto w-full max-w-md rounded-md bg-surface p-6 text-ink shadow-overlay backdrop:bg-ink/40
opacity-100 transition-[opacity,translate] duration-200 starting:open:opacity-0
starting:open:translate-y-2 motion-reduce:transition-none">…</dialog>
- Every control has hover,
focus-visible, disabled, and the ARIA state it exposes; a busy button says so (aria-busy, a spinner, its text kept). - Touch targets reach 44px on a phone:
h-11ormin-h-11, or padding on an icon button (p-2.5around a 24px icon). - Buttons: one filled primary per view (
bg-accent text-on-accent), the rest outline (border border-line) or plain (hover:bg-surface-2). Their variants live in onecva(section 7), not in copied strings.
7. Components, cn and class order
- A class group that repeats becomes a component (a React, Vue or Svelte
component, or a partial), not a copy and not
@applyeverywhere. Keep@applyfor base element styles and markup you do not control (HTML from a CMS). - Variants with
cva(class-variance-authority) or a small map keyed by variant (the Button inui-stack-react); merge a caller's classes withcn()(clsx and tailwind-merge) so a caller'spx-6replaces the default rather than both applying:
// lib/cn.ts
import { clsx, type ClassValue } from "clsx";
import { extendTailwindMerge } from "tailwind-merge";
// tailwind-merge knows only the default theme: name custom text sizes, or it
// reads text-display as a colour and drops it beside text-muted.
const twMerge = extendTailwindMerge({ extend: { theme: { text: ["display"] } } });
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
- tailwind-merge 3 is for Tailwind 4 (2 for 3). The
cnpackage (from the shadcn team, September 2026) replaces clsx and tailwind-merge together:export { cn } from "cn", andcreateCnfromcn/configtakes the sameextendfor custom tokens. @utilityfor a custom utility that takes variants:@utility focus-ring { outline: 2px solid var(--color-accent); outline-offset: 2px; }, thenfocus-visible:focus-ring.- In a Vue or Svelte
<style>block,@reference "../app.css";first, so@applyand theme values resolve without emitting Tailwind twice. - Keep class order readable: layout, box, type, colour, state, responsive.
The Prettier plugin sorts them, including inside
cn()andcva():
{
"plugins": ["prettier-plugin-tailwindcss"],
"tailwindStylesheet": "./src/app.css",
"tailwindFunctions": ["cn", "cva", "clsx"]
}
8. Motion
@theme {
--animate-rise: rise 480ms var(--ease-out-soft) both;
@keyframes rise {
from { opacity: 0; translate: 0 12px; }
}
}
- Animate
opacityandtranslate/scaleonly:transition-[opacity,translate], nevertransition-all(it animates layout and colours you did not mean). Durations and easing from tokens (duration-200 ease-out-soft). - Entry without JavaScript:
starting:(@starting-style) for elements that appear (a dialog, a popover, an item added to a list);motion-safe:on every animation,motion-reduce:to turn a library's off. - Content is visible in the markup; an animation only moves it into place.
A reveal that starts at
opacity-0needs the class that ends it to be certain to arrive, or the content stays hidden (motion-reveal).
9. Values, prose
- Use the scale. Arbitrary values (
w-[347px],text-[#1f2937],mt-[13px]) are the exception with a reason, not the way to match a mock-up; a value needed twice is a token. - A CSS variable as a value:
w-(--sidebar-width)in v4 (w-[--sidebar-width]was v3).size-10for equal width and height;min-h-dvh, neverh-screenon a section. - Long text from Markdown or a CMS:
@plugin "@tailwindcss/typography";andproseon the wrapper (65ch), styled from the tokens with element modifiers (prose-headings:font-display prose-a:text-accent) anddark:prose-invertor token overrides for the dark theme.
10. The generated look
Gradient text (bg-clip-text text-transparent), purple-to-pink gradients,
rounded-full on everything, shadow-2xl cards, backdrop-blur on
several surfaces, uppercase tracking-widest eyebrows over every heading,
slate or zinc greys with an indigo accent, rounded-2xl shadow-xl on every
card, ring-1 ring-white/10 glass panels on a dark gradient, h-screen
heroes, text-gray-400 body text that fails contrast. ui_check finds
them.
11. From v3 to v4
npx @tailwindcss/upgrade(Node 20+) on a clean branch, then read the diff: it moves the config into CSS, rewrites renamed classes and swaps the build plugin.- Renamed:
shadow-smtoshadow-xsandshadowtoshadow-sm(the same shift forrounded,bluranddrop-shadow),outline-nonetooutline-hidden,ringtoring-3,bg-gradient-to-rtobg-linear-to-r,flex-shrink-0toshrink-0,bg-opacity-50to a colour with/50. - Changed defaults:
borderandringarecurrentColor(give them a colour),ringis 1px, placeholders are the text colour at half opacity, buttons havecursor: default, andhover:applies only where the device can hover. !goes at the end (bg-accent!), stacked variants read left to right, and JS plugins load with@plugin; a JS config that must stay loads with@config "./tailwind.config.js".- In v3 itself:
theme.extendintailwind.config, pointing at CSS variables (colors: { ink: "var(--ink)" }), so a dark theme swaps the variables, not every class.
Check it
previewat 360, 768 and 1440px: itslayoutlines clear (section 4), both themes.ui_checkfor the generated look and raw values;grep -rnE "\[#[0-9a-fA-F]|-\[[0-9.]+px\]" srcfor arbitrary colours and sizes.- A misspelt class fails silently (it generates nothing): look at the
rendered page with the
previewtool, in both themes. - Prettier on changed files, so class order stays sorted and diffs small.
Avoid
The default palette in components (bg-purple-600, text-gray-500);
arbitrary values where a token exists; @apply to rebuild components in
CSS; outline-none with no focus style; dark: on every element instead of
tokens; hidden md:block duplicates of the same markup; cn without a
merge that knows the custom tokens; h-screen sections; the generated look
above; sections with their own width or padding instead of page; text
sizes outside the scale; transition-all; content that starts hidden.
04 同仓库更多 Skills
demo-skill
Project-scope demo skill for tests.
writing
Writing words people read: the specific thing instead of claims, words to drop, sentences, interface copy (buttons, labels, errors, empty states, confirmations), page copy, notifications and emails, alt text and link text, numbers and dates, words that translate well, documentation and voice. Read before writing a page's copy, an interface's text or a document.
ui
Designing or restyling an interface: direction, layout, type, colour, icons, components, states, responsive behaviour. Read before building a page, a screen or a set of components.
ui-themes
Light and dark themes, and colour that holds in both: tokens with a value per theme, and what each kind of content needs to stay visible (icons, SVG drawings and line art, logos, photos, screenshots, charts, code, maps, borders, shadows, focus, form controls), switching without a flash, and checking both. Read before building a second theme or a theme toggle, and before any drawing, logo or chart on a page that has two themes.
ui-structure
Structure before styling: the blueprint written before any interface code (the screen's job, the regions its kind of page needs, their order and why, the layout skeleton in numbers, the components and their states), the anatomy and order of a homepage, a dashboard, a list, a detail page, settings, a form, sign-in, pricing, docs, an article and a checkout, and how to turn the blueprint into code (tokens, layout primitives, components, sections, page). Read first, before building or rebuilding any page or screen.
ui-stack-vue
Building interfaces in Vue 3 and Nuxt: project layout, script setup with typed defineProps, defineEmits and defineModel, attribute fallthrough, slots and scoped slots, template refs, ref, reactive and computed, watch versus watchEffect, composables and VueUse, provide and inject, Pinia, keyed v-for, transitions, Nuxt 4 (app directory, pages, layouts, useFetch and useAsyncData keys, $fetch in handlers, server routes, runtime config, useSeoMeta, NuxtImg, middleware), VeeValidate forms, Reka UI and Nuxt UI, testing, vue-tsc, and the traps. Read when the project uses Vue or Nuxt.