Monthly revenue
Updated 3 hours ago
Across 1,204 accounts
Tailwind's border, radius, and shadow utilities as one elevation scale that tells a UI's surfaces how high to sit.
Take a card and give it three utilities:
<article className="rounded-lg border bg-card shadow-sm">…</article>It reads as a panel resting one step above the page, a thing you could pick up. Change one token, shadow-sm to shadow-2xl, and the same markup reads as a modal floating over the screen. The size, color, position, and radius are unchanged; only how high off the page it sits has changed.
Borders, rounded corners, and shadows aren’t three unrelated bits of polish. Together they are the design system’s elevation language, the way a UI tells you how its surfaces stack. A flush section, a bordered card, a card that lifts on hover, a dropdown, a dialog: each sits at a known height, and each height maps to a known utility you read off one ordered scale.
The last two lessons dressed the content of a box, the type it renders and the colors it carries. This one dresses the box itself. By the end you’ll take a bare <div> and dress it to sit at the right tier, give it a themable focus ring that doesn’t shift the layout, and reach for a frosted-glass header when a 2026 web app asks for one, all off the token scale with no hand-picked values.
A border is the flattest separation tool you have. Two surfaces often don’t need to stack; they just need a line between them so the eye knows where one ends and the next begins, and a border is the cheapest way to draw it.
The bare utility is border:
<div className="border">…</div>That compiles to 1px solid var(--color-border), a one-pixel hairline in your theme’s border color. The color comes from the --color-border token, the same semantic-token machinery you used for backgrounds and text, so the line recolors itself in dark mode without a second class. Bare border works because of Tailwind’s reset: in raw CSS, setting border-style without a color falls back to currentColor, so a plain border would inherit your text color. Preflight sets a sensible --default-border-color instead, giving you a working hairline rather than a black outline matching your font.
That hairline is your default. When you need more, the scale grows in two directions: thickness and sides.
<div className="border-2">…</div><div className="border-t border-b">…</div><div className="border-s-4">…</div>border-2 and border-4 thicken the line for emphasis. The side utilities draw a line on a single edge: border-t / border-b for top and bottom, border-x / border-y for the pairs. Prefer the logical sides, border-s (start) and border-e (end), over the physical border-l / border-r. In a left-to-right language border-s is the left edge, but in an RTL script it flips right automatically, the same habit you built with text-start and text-end.
The color is overridable, and the rule from the color lesson holds with no exceptions: reach for a semantic token, never a raw palette step.
<input className="border border-input" /><div className="border border-destructive">…</div><div className="border border-white/10">…</div>border-input is the form-field border, border-destructive the error state, border-primary the accent. You write the role, the theme supplies the value, and dark mode flips it. The one form worth memorizing is border-white/10, a barely-there translucent line whose /10 is the per-channel alpha you already know. On a dark surface, a 10%-white hairline is the standard divider, far more natural than a specific gray that only works at one background lightness.
Style is the last axis, and it barely counts. border-dashed and border-dotted exist; everything past them, such as border-double, you only need to recognize. Dashed earns its place in exactly one situation: it signals absence. An empty-state placeholder where content will go, or a drop zone waiting for a dragged file, reads as “nothing here yet, put something here.” A solid border says “here is a thing”; a dashed border says “here is where a thing goes.”
divide-yOne more border tool solves a problem you’ll hit constantly: a stacked list of rows that needs a line between each one but not above the first or below the last. Put a border on every row and you spend the rest of your time fighting the outer edges.
<ul> <li className="border-b border-border last:border-b-0 py-3">Profile</li> <li className="border-b border-border last:border-b-0 py-3">Billing</li> <li className="border-b border-border last:border-b-0 py-3">Team</li></ul>Every row repeats the border, and the last edge needs a special case. Each <li> carries border-b, then last:border-b-0 peels the trailing line off the final row: easy to forget, noisy to read, and duplicated on every item.
<ul className="divide-y divide-border"> <li className="py-3">Profile</li> <li className="py-3">Billing</li> <li className="py-3">Team</li></ul>One class on the parent draws a hairline between every pair of children. divide-y skips the outer top and bottom edges by design, so the rows stay clean with no per-row border and no first/last override.
divide-y puts a hairline between every stacked child and leaves the top and bottom edges clean, exactly what a settings list, a menu, or a table of rows wants. divide-x does the same horizontally, and divide-border reaches for the same token a border would. The habit: a stack of rows that needs separators gets divide-y divide-border on the container, not a border on each row.
--radius tokenBorders draw lines; radius softens corners. The scale is simple, so what matters is the discipline of using it.
The values run rounded-xs (2px), rounded-sm (4px), rounded-md (6px), rounded-lg (8px), rounded-xl (12px), rounded-2xl (16px), rounded-3xl (24px), rounded-4xl (32px), plus rounded-full and rounded-none at the ends. Per-corner and logical variants (rounded-t-lg, rounded-bl-md, rounded-s-lg, rounded-ss-*) exist for the rare asymmetric case.
A design system picks one or two radius values and reuses them everywhere. A view where some cards are rounded-md and others rounded-2xl reads as a mistake, the same way mismatched font sizes do. This is the “write off the scale” discipline from the type lesson, pointed at corners: the scale exists so your whole UI agrees on one curve.
A single knob enforces that. shadcn exposes one token, --radius, and its components don’t hard-code their corners; they derive them from it. A card computes rounded-xl-ish corners as an offset from --radius, a button a slightly tighter one, an input tighter still, all anchored to the same root. Tune one variable and the whole app re-rounds in step.
:root { --radius: 0.625rem;}That line is the whole authoring move for radius in most projects. You consume --radius the way you consume the color tokens: through the components, reaching in once in a while to turn it. Bump it up and the app turns pillowy; drop it to 0 and everything goes sharp. Either way it stays consistent, because there’s one source.
rounded-full does not mean “circle.” It means “round the corners as much as geometry allows.” On a square element that gives a circle; on a wider-than-tall element it gives a pill : flat top and bottom, semicircular ends. Both are correct, and both are useful, since a pill is the standard shape for a badge or tag button. The surprise comes when you wanted a circle but applied rounded-full to a non-square box and got a stretched lozenge. Make the box square first:
<img className="aspect-square rounded-full" src={avatarUrl} alt="" />aspect-square (which you met with sizing) forces equal width and height, and then rounded-full has a square to round into a circle. That pairing is the avatar pattern.
rounded-sm sm rounded-lg lg rounded-2xl 2xl rounded-full full → pill Borders separate and radius softens; shadow lifts. A shadow is how a flat screen tells you one surface floats above another, and because depth is continuous, the shadow utilities form a ladder where each rung sits one step higher off the page.
The rungs, from faint to dramatic: shadow-2xs, shadow-xs, shadow-sm, shadow-md, shadow-lg, shadow-xl, shadow-2xl, and shadow-none at the floor. For surfaces that read as pressed in rather than lifted out, such as a well or an inset slot, a separate family puts the shadow on the inside: inset-shadow-2xs / inset-shadow-xs / inset-shadow-sm.
What matters more than the list is that the ladder maps onto your UI. Every surface sits at a recognizable tier, and each tier has a rung. Learn the mapping and you stop guessing shadows.
shadow-lg shadow-md shadow-md shadow-md shadow-sm shadow-none Read it as a sentence: a card at rest is shadow-sm; lift it on hover to shadow-md; a dropdown or tooltip sits at shadow-md; a dialog floats higher at shadow-lg or shadow-xl. One step per tier. That’s the discipline, and the most common mistake is skipping rungs. shadow-2xl is modal height, so a card wearing it reads as a dialog that forgot to open. When a card looks “off,” the usual cause is a shadow one or two rungs too tall for the surface.
That hover lift needs transition-shadow so it animates instead of snapping. We name it only so the lift isn’t jarring; motion is its own lesson later in this chapter.
It tints itself to whatever’s underneath. Tailwind’s shadows are a translucent black, built from rgb(0 0 0 / 0.1) and similar low-alpha values, and a translucent layer composites over whatever surface color sits beneath it. So the same shadow-sm darkens a light card and a dark one each by the right amount, with no per-theme tuning. That’s the /N alpha-over-a-surface idea from the last lesson paying off, the same reason a bg-black/50 backdrop dims whatever page sits behind it.
Each rung is more than one shadow. A real object casts two: a tight, dark contact shadow right where it touches the surface, and a soft, wide ambient shadow spread around it. Each shadow-* rung stacks several box-shadow values to fake exactly that. You don’t author it, Tailwind ships it in the scale, but it’s why a single hand-written box-shadow looks flat and cheap next to the utility. The depth reads as real because it’s layered.
The shadow can be colored, when you mean it. shadow-blue-500/50 tints the shadow itself into a glow. The use is narrow and intentional: a brand-tinted hover glow on a feature card, a colored halo on an active state. The default shadow is neutral, so treat every colored shadow as a deliberate brand decision.
Drive the controls and watch a single card cross from flush to floating.
Monthly revenue
Updated 3 hours ago
Across 1,204 accounts
Every position of those controls is a className you could paste into real code: the visual decision and the utility are the same decision.
outline, not borderOne decoration appears and disappears as the user moves through the page: the focus ring, the highlight on whatever element the keyboard is currently on. It looks like a border, so the instinct is to draw it with one. That instinct produces a bug.
A border occupies layout space, because it’s part of the box’s size. Add a 2px border only on focus and the element grows by 2px on every side the instant focus lands, pushing every neighbor aside to make room. Reflow ripples out from the focused element, and tabbing through a form drawn this way makes the whole layout twitch at every step.
An outline is drawn on top of the layout, outside the box, taking no space, so it appears and vanishes without moving a single pixel. That is why focus rings are outlines, not borders. It’s geometry, not aesthetics.
The first tab below fakes the ring with a border; the second with an outline. Watch the neighbors.
Two details remain: when the ring shows, and what color it is.
A focus ring must be visible, because a keyboard user who can’t see where they are is stranded; that’s a hard accessibility requirement. But it should show only on keyboard focus, not on a mouse click, where a ring flashing on every click is noise. That’s the job of the focus-visible: variant. Always write the ring through it, never as a bare outline, which would show on every interaction:
<button className="focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"> Save</button>outline-2 is the width. outline-offset-2 pushes the ring a couple of pixels off the element so it sits in a clean gap instead of hugging the edge. outline-ring is the themable ring-color token, so the ring recolors with your theme.
For now, treat focus-visible: as the keyboard-focus ring you write as a variant; the pseudo-class that powers it is the next lesson’s job.
ring-*: the shorthand shadcn shipsThere’s a second way to draw the ring, and it’s the one you’ll read in shadcn’s buttons and inputs. Instead of outline, they use the ring-* utilities:
<button className="focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background"> Save</button>This produces a halo , a ring with a gap, for three reasons. It follows rounded corners, so the ring curves around a rounded-lg button instead of cutting square. The gap color is themable through ring-offset-background, so the inner gap matches whatever surface the element sits on. And ring-ring/50 uses the alpha syntax you know for a half-opacity ring softer than a solid line.
One mechanical fact: ring-* is implemented as box-shadow layers, not a real outline. That’s how it gets the offset and follows rounded corners, and it’s why combining a heavy shadow-* and a ring-* on the same element occasionally needs care: they share the same shadow channel.
The habit to carry out: every interactive element, button, link, input, or select, gets a focus-visible: ring written as a variant, never a bare outline.
These utilities combine into the kind of className you’ll read in a real component. Here it is one piece at a time.
<article className="rounded-lg border bg-card text-card-foreground shadow-sm transition-shadow hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2"> …</article>The radius, derived from the design system’s one --radius value. Every card shares this curve, the consistency the single knob buys you.
<article className="rounded-lg border bg-card text-card-foreground shadow-sm transition-shadow hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2"> …</article>The hairline. Bare border is a 1px line in the theme’s border color, separating the card from the page behind it.
<article className="rounded-lg border bg-card text-card-foreground shadow-sm transition-shadow hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2"> …</article>The paired surface tokens from the color lesson, giving the card the right background and a readable foreground in both light and dark themes.
<article className="rounded-lg border bg-card text-card-foreground shadow-sm transition-shadow hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2"> …</article>The resting elevation, one step above the page. This is the rung that makes the box read as a card and not a flush section.
<article className="rounded-lg border bg-card text-card-foreground shadow-sm transition-shadow hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2"> …</article>The hover lift, one rung up, with a transition so it eases instead of snapping. (Motion and the hover: pseudo-class are later lessons.)
<article className="rounded-lg border bg-card text-card-foreground shadow-sm transition-shadow hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2"> …</article>The keyboard focus ring, a themable halo with a gap. outline-none removes the browser default so the custom ring is the only one that shows.
Radius, hairline, surface, resting elevation, hover lift, and focus ring: every decision from this lesson in one className, each part doing one job. A wall of classes like this is a sentence in the elevation language, not a pile of utilities.
drop-shadow vs box-shadowThere’s a second shadow primitive, and the difference between the two is purely about shape.
box-shadow casts a shadow from the element’s rectangular bounding box. It’s always a rectangle, regardless of what’s drawn inside, which is exactly right for a card or a button.
drop-shadow is a filter , and a filter works on the element’s rendered shape, not its box. It traces transparent regions, irregular outlines, the actual silhouette. So a star icon, a logo with a cut-out, or an SVG glyph with transparent corners casts a shadow shaped like itself with drop-shadow, and a clumsy rectangle with box-shadow.
box-shadow shadows the rectangular box — wrong for this shape drop-shadow shadows the rendered silhouette — right Reach for box-shadow by default, since almost everything is rectangular. Reach for drop-shadow only when the shape is irregular and a rectangular shadow would look broken: icons, transparent logos, cut-out images.
Two costs keep drop-shadow from being a free default. As a filter, it creates a stacking context (the same way transform does, which you saw in the layout chapter). And it’s more expensive to paint, since the browser has to shadow an arbitrary shape rather than a rectangle. Neither is a reason to avoid it, only a reason to reach for it when the shape demands it.
backdrop-filter: the frosted-glass headerYou’ve seen sticky site headers that are slightly see-through, where the page content scrolls underneath and goes soft and blurry while the header text stays crisp. That’s glass-morphism , and it comes down to one property: backdrop-filter.
The part people get backwards: a backdrop filter blurs the content behind a semi-transparent element, not the element’s own content. Plain blur would smear the element’s own text into mush. backdrop-blur leaves that text perfectly sharp and blurs only what shows through from behind.
The canonical use is exactly that sticky header:
<header className="sticky top-0 bg-background/70 backdrop-blur">…</header>bg-background/70 makes the header 70% opaque, see-through enough to reveal what’s behind it (the alpha syntax again). backdrop-blur frosts whatever shows through. Together, page content scrolls under the header and softens while the header’s own text rides on top, fully legible. There’s also backdrop-saturate-* and backdrop-brightness-* to tune the tint, but blur does the work. In a 2026 web app, this sticky header is where glass-morphism earns its weight, rather than a decorative effect sprinkled everywhere.
Now build the whole surface yourself. The exercise gives you a bare card and a finished target; dress the bare one until it matches.
Match the target. The card's surface colors are already in place — your job is the elevation language. Give the card its radius, a hairline border, a resting elevation, a hover lift (with a transition so it eases), and a keyboard focus ring on the button inside. Hover the card and tab to the button to check your work.
The target uses literal utilities like bg-white dark:bg-zinc-900 and ring-blue-500/50 only because the sandbox can’t load a project’s theme tokens. In real app code you’d write the semantic versions, bg-card, border-border, and ring-ring, and let the theme supply the values. Same shapes, themable source.
Next, the shadow decision: for each item below, pick the shadow primitive that fits its shape.
Each surface needs a shadow. Sort each one into the shadow primitive that fits its shape. Drag each item into the bucket it belongs to, then press Check.
Finally, two checks on the mistakes most likely to catch you.
Focus rings are drawn with outline (or ring-*), not border. You ignore that and add a 2px border to a button only while it’s focused. Tab through a form of these buttons — what do you see?
border color can’t read from a theme token.rounded-lg button, because a border can’t follow rounded corners.outline (and ring-*) is painted on top of the layout and takes no space, so it appears and vanishes without moving a pixel. That geometry — not aesthetics — is the whole reason focus rings are outlines.A teammate’s pricing card “looks like a dialog that forgot to open” — it floats too high off the page when it should just rest one rung above it. They’re reaching too far up the elevation ladder. Which utility puts it back at the resting-card tier?
shadow-smshadow-2xlshadow-noneinset-shadow-smshadow-sm is the fix. shadow-2xl is the rung you’d reach for the floating-modal look they’re complaining about — it’s the cause, not the cure. shadow-none drops the card flat onto the page, losing the one-step lift a card needs. And inset-shadow-sm carves the surface inward like a pressed well, the opposite of lifting it out.You can now read a wall of utility classes on a shadcn card and see what it says: this surface sits at this tier, with this curve, separated by this hairline, ringed this way on focus. Borders, radius, and shadows have become a language with a grammar: one ordered scale, one or two radii, one step of elevation per tier.
Everything you dressed here was a surface at rest. The focus ring was the one hint of more, a style that switches on only when the user interacts. You wrote focus-visible: and hover: and trusted them; next you’ll learn what’s behind those colons: the pseudo-classes that decide when a style applies, namely :focus-visible, :hover, and :has(), the parent selector that quietly retired a generation of JavaScript.
The Tailwind v4 docs are the canonical reference for the exact step names and values on each scale. The two interactive pieces from Josh Comeau are where the why behind layered shadows and frosted glass clicks into place.
Interactive guide to why each rung stacks several layered shadows, with a palette generator.
The glass-morphism header taken further — edge flicker, blur radius, and layered depth.
The full elevation scale, inset-shadow family, and colored-shadow syntax.
Every radius step, the per-corner and logical variants, and rounded-full.