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.
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
| Utility | CSS property | Typical use here | Count |
|---|---|---|---|
px-* | padding-inline | Control interiors, card gutters | 47× px-4 alone |
py-* | padding-block | Section rhythm, button height | 27× py-3 alone |
p-* | padding (all sides) | Square-ish cards and icon wells | 12× p-6, 12× p-4 |
pt-* / pb-* | One block edge | Asymmetric sections, header clearance | 13× pt-30, 15× pb-16 |
pl-* / pr-* | One inline edge | Rare — 1× pr-[6.625rem] | 1 arbitrary |
ps-* / pe-* | Logical inline edges | Not used — site is LTR-only today | 0× |
p*-[…] | Verbatim CSS | Ported pixel values, safe-area insets | 12× |
.container rule | padding-inline in CSS | The page gutter on every route | Every 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
| Symptom | Cause | Fix |
|---|---|---|
A px-* class on a .container element does nothing | The .container rule already sets padding-inline; both land at the same specificity and stylesheet order decides | Wrap 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 mobile | A single unprefixed py-* applied at every width | Scale it: py-16 md:py-24 lg:py-30, smallest value unprefixed |
| A bottom-anchored element is overlapped by the phone's home indicator | Fixed padding can't know about the device inset | pb-[max(0.5rem,env(safe-area-inset-bottom))], exactly as PreviewModal does |
p-4 on a flex parent doesn't separate the children | Padding insets the container, it doesn't space siblings | Use gap-* on the flex/grid parent |
An arbitrary value like pt-[ 90px ] silently fails | Tailwind passes brackets through unmodified, and spaces inside them break the generated class | Remove the spaces: pt-[90px]; use underscores where a real CSS space is required |
| Padding on an inline element ignores the vertical part | padding-block on display: inline doesn't affect line box height | Add inline-flex or inline-block, as Button's base does |
Prettier reorders classes around a p-* utility | prettier-plugin-tailwindcss sorts by Tailwind's internal order, not alphabetically | Expected — 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.
