Skip to main content
ASoc
Tutorial

Tailwind Padding: 432 Instances, and the One That Isn't a Utility

A census of every p-*, px-* and py-* in this codebase — 89 distinct values — plus why the site's page gutter lives in globals.css instead.

The ASoc Team10 min read

Tailwind's padding utilities write the CSS padding property from a spacing scale (p-4, px-6, py-3) or an arbitrary value (pb-[110px]). This codebase carries 432 padding instances across 89 distinct values — and the single most important piece of horizontal padding on the site isn't a utility at all. It lives in globals.css.

The short answer

p-<n> sets all four sides. Axis variants narrow it: px-* is left+right (padding-inline), py-* is top+bottom (padding-block). Side variants take one edge: pt-*, pr-*, pb-*, pl-*. Logical variants ps-*/pe-* follow text direction. Square brackets accept any CSS value verbatim: pt-[90px], pb-[max(0.5rem,env(safe-area-inset-bottom))].

Each numeric step multiplies Tailwind v4's spacing unit, which defaults to 0.25rem. So p-4 is 1rem, py-3 is 0.75rem top and bottom, and py-2.5 is 0.625rem. This project never overrides that unit — there's no --spacing declaration in its @theme block — so every number below maps to the stock scale, exactly as the h-* utilities do.

What 432 padding instances actually look like

Counted across src/components and src/app:

432 total padding instances, 89 distinct values

 47  px-4      27  py-3      17  py-2.5    15  px-6
 15  pb-16     14  px-5      14  px-3      13  py-2
 13  pt-30     12  p-6       12  p-4       11  py-4
 11  pb-24      9  p-2        8  py-16      8  px-2
  7  py-20      7  py-1.5     7  py-1       7  px-8
  7  pt-20      7  p-8        7  p-5        6  py-24

The distribution says something specific about where padding work happens in a real project. The top of the list is not page layout — px-4, py-3, py-2.5, px-3, py-2 are control padding: the inside of buttons, pills, badges, form fields and menu items. Section-scale padding (pb-16, pt-30, py-20, pb-24) sits lower in the count but accounts for far more pixels.

That split matters when you're reading someone else's Tailwind. A high px-4 count doesn't mean the page gutters are 1rem everywhere; it means a lot of small interactive things each needed a comfortable tap target.

The most-reused padding on the site is one string in one atom

px-6 py-3 appears on every call-to-action across the storefront, and it's written exactly once:

// src/components/atoms/Button.tsx
const base =
  "inline-flex items-center justify-center gap-2 rounded-lg px-6 py-3 text-base font-medium shadow-xs duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2";

const variants: Record<ButtonVariant, string> = {
  primary: "bg-primary text-white hover:bg-primary-600",
  outline:
    "border border-stroke-tertiary bg-white text-text-color hover:bg-gray-50 hover:text-gray-800",
  dark: "bg-gray-900 text-white hover:bg-gray-800",
};

Note what's in base and what's in variants. Padding is in base because a CTA's hit area should not change when its colour does — an outline button and a primary button are the same control wearing different paint. Splitting them the other way (padding per variant) is how button sets drift half a step apart and nobody can say when it happened.

This is the whole argument for extracting an atom rather than repeating a utility string. px-6 py-3 is 1.5rem horizontal, 0.75rem vertical; changing that ratio site-wide is a one-line edit here, and there is no second place where a stale copy could survive.

Section rhythm: padding that scales at breakpoints

Vertical section spacing is where the responsive variants earn their keep. A representative organism:

// src/components/organisms/Features.tsx
<section className="relative z-10 bg-[linear-gradient(...)] py-16 md:py-24 lg:py-30">

py-16 (4rem) on a phone, py-24 (6rem) from md, py-30 (7.5rem) from lg. Three values, one class list, no media query written by hand. The reason to scale it at all: 7.5rem of vertical air reads as generous on a 1440px desktop and as a scrolling chore on a 390px phone, where the same gap consumes a fifth of the viewport.

pt-30 at 13 instances and pb-24 at 11 are the same idea applied asymmetrically — sections that need clearance under the sticky header get top padding specifically, not py-* on both edges.

The one padding that isn't a utility

Every page on this site is horizontally inset by a rule in the stylesheet, not by a class in the markup:

/* src/app/globals.css — responsive page container */
.container {
  width: 100%;
  margin-inline: auto;
  padding-inline: 1rem;
}
@media (min-width: 425px)  { .container { max-width: 425px; } }
@media (min-width: 640px)  { .container { max-width: 640px; } }
@media (min-width: 768px)  { .container { max-width: 768px; } }
@media (min-width: 1024px) { .container { max-width: 1024px; } }
@media (min-width: 1280px) { .container { max-width: 1280px; } }
@media (min-width: 1440px) { .container { padding-inline: 4rem; } }
@media (min-width: 1536px) { .container { max-width: 1536px; padding-inline: 3rem; } }

And the atom that applies it does nothing else:

// src/components/atoms/Container.tsx
export default function Container({
  className = "",
  children,
}: {
  className?: string;
  children: ReactNode;
}) {
  return <div className={`container ${className}`.trim()}>{children}</div>;
}

padding-inline is the site's page gutter — the reason text never touches the edge of a phone screen. Read the breakpoints and it turns into three values, not one: 1rem up to 1440px, 4rem from 1440px, 3rem from 1536px.

That sequence is the reason this lives in CSS rather than in a utility string, and it's worth reading closely because it looks like a mistake. The gutter grows at 1440px while the max-width is unchanged, then shrinks at 1536px as the max-width jumps to 1536px. In other words the padding is doing the work the max-width isn't: between 1440 and 1536 the container is still capped at 1280px, so the extra 4rem of inline padding is what keeps content off the edge of a wide window; once the cap rises to 1536px the container is doing that job itself and the gutter can relax.

Padding changing independently of max-width at a breakpoint is exactly the shape Tailwind's built-in container utility can't express — its single padding key applies uniformly. It is also the closest this project came to needing a plugin, and the reason it still didn't install one. Expressed as utilities you'd need something like px-4 min-[1440px]:px-16 min-[1536px]:px-12 repeated at every call site, and the moment one section missed the update the gutters would silently disagree.

The trade-off is real and worth stating, because "put it in the stylesheet" is not the default Tailwind advice: you give up per-call-site overrides, since px-* on the same element fights padding-inline at equal specificity and resolves by source order. In exchange the gutter cannot drift between two sections. For a container used on every route, that's the right side of the trade.

Arbitrary values: 12 instances, and one of them is load-bearing

Bracket syntax passes a value through to CSS untouched. The full census here:

 4  pb-[110px]     2  py-[110px]     1  pb-[120px]     1  pt-[120px]
 1  pt-[90px]      1  px-[115px]     1  pr-[6.625rem]
 1  pb-[max(0.5rem,env(safe-area-inset-bottom))]

Seven of those eight distinct values are pixel numbers from the design this storefront was ported from — spacing that lands between scale steps and was kept verbatim rather than rounded to the nearest utility. That's a legitimate use of brackets, and an honest one: pt-[90px] says "this number came from a comp," where pt-22 would imply a scale position it doesn't occupy.

The eighth is doing real work:

// src/components/molecules/PreviewModal.tsx
<div className="flex flex-1 items-stretch justify-center overflow-hidden p-2 pb-[max(0.5rem,env(safe-area-inset-bottom))] md:p-4">

env(safe-area-inset-bottom) is the browser-reported height of the home-indicator area on a notched phone. max(0.5rem, …) takes whichever is larger, so the modal's bottom padding is 0.5rem on hardware with no inset and grows to clear the indicator on hardware that has one. No device detection, no user-agent branch, no JavaScript.

This is the case no spacing scale can express, because the correct value isn't known until the page runs on a specific device. The same pattern appears on the modal's floating device toggle (max-md:bottom-[max(0.75rem,env(safe-area-inset-bottom))]), for the same reason: both sit at the bottom edge of a full-height overlay that phones are expected to use.

Comparison: which padding utility for which job

UtilityCSS propertyTypical use hereCount
px-*padding-inlineControl interiors, card gutters47× px-4 alone
py-*padding-blockSection rhythm, button height27× py-3 alone
p-*padding (all sides)Square-ish cards and icon wells12× p-6, 12× p-4
pt-* / pb-*One block edgeAsymmetric sections, header clearance13× pt-30, 15× pb-16
pl-* / pr-*One inline edgeRare — 1× pr-[6.625rem]1 arbitrary
ps-* / pe-*Logical inline edgesNot used — site is LTR-only today
p*-[…]Verbatim CSSPorted pixel values, safe-area insets12×
.container rulepadding-inline in CSSThe page gutter on every routeEvery page

Two rows are worth reading as findings rather than reference. ps-*/pe-* at zero is a deliberate scope decision, not an oversight — those utilities only pay off once a layout has to mirror for right-to-left text, and adopting them preemptively costs legibility for a benefit nothing currently claims. And pl-*/pr-* being nearly absent is what you'd expect from a design that thinks in symmetric gutters: when only one inline edge needs padding, it's usually a sign the element should have been laid out with gap instead.

Padding versus gap: the distinction that removes most one-off padding

A large share of "I need padding on only one side" turns out to be spacing between siblings, which is a flex or grid concern, not a padding one. Button's base string shows both in one line: gap-2 separates the label from an optional icon, while px-6 py-3 insets the pair from the button's own border. Padding is the space inside a box; gap is the space between boxes. Reaching for pl-2 on the second child to fake a gap works until the children wrap, reorder, or one of them disappears — at which point the fake gap is attached to the wrong thing and the real one never existed.

Troubleshooting

SymptomCauseFix
A px-* class on a .container element does nothingThe .container rule already sets padding-inline; both land at the same specificity and stylesheet order decidesWrap the content in an inner element and pad that, or adjust the .container rule itself if the gutter is genuinely wrong site-wide
Padding looks right on desktop, cramped on mobileA single unprefixed py-* applied at every widthScale it: py-16 md:py-24 lg:py-30, smallest value unprefixed
A bottom-anchored element is overlapped by the phone's home indicatorFixed padding can't know about the device insetpb-[max(0.5rem,env(safe-area-inset-bottom))], exactly as PreviewModal does
p-4 on a flex parent doesn't separate the childrenPadding insets the container, it doesn't space siblingsUse gap-* on the flex/grid parent
An arbitrary value like pt-[ 90px ] silently failsTailwind passes brackets through unmodified, and spaces inside them break the generated classRemove the spaces: pt-[90px]; use underscores where a real CSS space is required
Padding on an inline element ignores the vertical partpadding-block on display: inline doesn't affect line box heightAdd inline-flex or inline-block, as Button's base does
Prettier reorders classes around a p-* utilityprettier-plugin-tailwindcss sorts by Tailwind's internal order, not alphabeticallyExpected — per this repo's convention, class order is cosmetic as long as every class survives

Frequently asked questions

What is p-4 in pixels? 1rem, which is 16px at the default root font size. Each numeric step is 0.25rem (4px) in Tailwind v4 unless a project overrides the --spacing theme variable — this one doesn't, so p-4 is four steps, py-2.5 is 0.625rem, and pt-30 is 7.5rem.

What's the difference between px-* and ps-*? px-* sets physical left and right padding. ps-* and pe-* set logical start and end padding, which swap sides in a right-to-left writing mode. If a site will never render RTL, px-* is simpler to read; if it might, the logical pair saves a full mirroring pass later. This codebase uses px-* exclusively today.

Should padding live in a utility class or in globals.css? Utilities by default — they keep the value visible at the call site. Move it to CSS when the padding is inseparable from other rules that already live there, as with this project's .container, where the gutter and the responsive max-width ladder are a single decision applied on every route.

Why use an arbitrary value instead of extending the spacing scale? Extend the scale when a value is a genuine design step that will recur. Use brackets when it won't — a ported pixel measurement or a runtime-resolved value like env(safe-area-inset-bottom). Of the 12 arbitrary padding values here, seven are one-off comp measurements and one is an environment value that no static scale could hold.

Templates in this post

ASoc Tune, ASoc Vogue and ASoc Volt are Next.js + Tailwind ecommerce templates built on the spacing conventions audited above — CTA padding centralised in one button component, section rhythm scaled across breakpoints, and safe-area insets handled on the overlays that phones actually use.

Browse the full sets: Next.js shop templates, Tailwind shop templates.

Keep reading

Tutorial11 min read

Scroll-Driven Animations in Tailwind v4 Without a JS Library

animation-timeline replaces the scroll-animation library category — behind two guards. The Tailwind v4 setup, the element you must never fade in, and when IntersectionObserver still wins.

Read more