Skip to content
Chapter 21Lesson 3

Borders, radius, and the elevation scale

How Tailwind's border, radius, and shadow utilities combine into the elevation language 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. Now change exactly one token, shadow-sm to shadow-2xl, and the same markup reads as a modal floating over the whole screen. You didn’t change the size, the color, the position, or the radius. You changed how high off the page it sits.

That single swap is the idea this lesson turns on. Borders, rounded corners, and shadows aren’t three unrelated bits of polish you sprinkle on at the end. 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, a tooltip: each sits at a known height, and each height maps to a known utility. So the skill is to treat these utilities as one ordered scale you read top to bottom, rather than as a decision you make from scratch for every component.

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 be able to take a bare <div> and dress it to sit at the right tier, whether that’s flush, a card, a hover lift, or a dialog. You’ll also give it a themable focus ring that doesn’t shift the layout, and reach for a frosted-glass header when a 2026 SaaS asks for one. All of it comes off the token scale, with no hand-picked values.

Before we get to height, start with the flattest tool in the box. Sometimes two surfaces don’t need to stack at all; they just need a line between them so the eye knows where one ends and the next begins. A border is the cheapest way to draw that line.

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. Two things about it are worth pinning down. The first is where the color comes from. It comes from the --color-border token, the same semantic-token machinery you set up for backgrounds and text, so the line recolors itself in dark mode without you writing a second class. The second is why bare border works at all. In raw CSS, setting border-style without a color falls back to currentColor, so a plain border would inherit the text color and surprise you. Tailwind’s reset sets a sensible --default-border-color instead, which is why border on its own gives you a working hairline rather than a black outline matching your font. You met that reset when we covered Preflight, and this is it quietly paying off.

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 let you draw a line on just one 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 to the right automatically. This is the same habit you built with text-start and text-end: default to the logical sides and your UI mirrors itself for free.

The color is overridable, and here 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 on its own is border-white/10, a barely-there translucent line. That /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 picking a specific gray that only works at one background lightness.

Style is the last axis, and it barely counts as one. 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 in modern UI: it signals absence. Think of an empty-state placeholder where content will go, or a drop zone waiting for a dragged file. A dashed border reads as “nothing here yet, put something here,” which is a specific signal rather than decoration. A solid border says “here is a thing”; a dashed border says “here is where a thing goes.”

There’s one more border tool, and it 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. The naive approach puts a border on every row and then leaves you fighting the outer edges.

In the following comparison, the first tab shows that fight; the second shows the tool that ends it.

<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 has to peel the trailing line off the final row. It’s easy to forget, noisy to read, and duplicated on every item.

divide-y puts a hairline between every stacked child and leaves the top and bottom edges clean, which is exactly the behavior a settings list, a menu, or a table of rows wants. divide-x does the same horizontally. The color works just like a border, since divide-border reaches for the same token. The habit to build is this: a stack of rows that needs separators gets divide-y divide-border on the container, not a border on each row.

Borders draw lines; radius softens corners. The scale itself is straightforward, so the part worth your attention is the discipline of how you use it.

The list, for reference: 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. For the rare time you need asymmetry, there are per-corner variants (rounded-t-lg rounds the top two corners, rounded-bl-md the bottom-left one) and logical ones (rounded-s-lg, and rounded-ss-* for the start-start corner). Recognize them and move on; you’ll reach for them maybe once a project.

Here is the part that matters. A design system picks one or two radius values and reuses them everywhere. A view where some cards are rounded-md and others are rounded-2xl doesn’t read as variety; it reads as a mistake, the same way mismatched font sizes do. This is the “write off the scale” discipline from the type lesson, now pointed at corners: the scale exists so your whole UI agrees on one curve.

The clean way to enforce that is a single knob. shadcn exposes one token, --radius, and its components don’t hard-code their corners; they derive them from that token. 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 value. So you tune one variable and the whole app re-rounds in step.

app/globals.css
:root {
--radius: 0.625rem;
}

That one line is the whole authoring move for radius in most projects. You consume this token the same way you consume the color tokens: you read it through the components, and once in a while you reach in and turn it. Bump it up and the app turns pillowy; drop it to 0 and everything goes sharp-cornered. Either way it stays consistent, because there’s one source.

Two endpoints of the scale deserve a closer look, because one of them trips up nearly everyone.

rounded-full does not mean “circle.” It means “round the corners as much as geometry allows.” On a square element that gives you a circle. On a wider-than-tall element it gives you a pill : flat top and bottom, semicircular ends. Both are correct outputs of the same utility, and both are useful, since a pill is the standard shape for a badge or a tag button. The surprise comes only when you wanted a circle but applied rounded-full to a non-square box and got a stretched lozenge. The fix is to 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; commit it to muscle memory.

To see the scale as one family, the following strip shows the same card at four points along it.

rounded-sm sm
rounded-lg lg
rounded-2xl 2xl
rounded-full full → pill
The same card across the radius scale. The rightmost shows `rounded-full` on a non-square box producing a pill, not a circle. A design system commits to one or two of these and reuses them.

Now we reach the axis the whole lesson has been building toward. Borders separate and radius softens; shadow lifts. A shadow is depth: it’s 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 is 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, there’s a separate family, inset-shadow-2xs / inset-shadow-xs / inset-shadow-sm, that puts the shadow on the inside.

What matters more than the list is that the ladder maps onto your UI. Every surface in an app sits at a recognizable tier, and each tier has a rung. Once you learn that mapping, you stop guessing shadows.

Dialog / modal
shadow-lg
Tooltip
shadow-md
Dropdown / popover
shadow-md
Hover-lifted card
shadow-md
Resting card
shadow-sm
Page / base surface
shadow-none
The elevation ladder, where every surface tier maps to one shadow rung. The rule is one step per tier, read top to bottom: a dialog floats highest, the page sits flush on the floor.

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 on a card is almost always wrong, because 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 that’s one or two rungs too tall for the surface.

That hover lift needs a transition so it doesn’t snap, which means pairing it with transition-shadow. We’re naming that here only so the lift isn’t jarring; motion is its own lesson, coming up next.

A few things make the scale work, and each is worth a moment.

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, and you don’t hand-tune a shadow per theme. 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 is behind it.

Each rung is more than one shadow. A real object casts two shadows: 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, since 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, turning it 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 brand decision you make on purpose.

One gotcha catches people often, so it’s worth knowing in advance.

Reading about depth only gets you so far, so this is the part to play with directly. Drive the controls and watch a single card cross from flush to floating.

One card, three independent layers. Slide the elevation from none up the ladder and watch the card cross from flush to floating; flick the border to feel it's a separate layer; read the exact className off the chip.

Notice what the readout chip shows you: every position of those controls is a className you could paste into real code. That’s the whole point of treating these as a scale, since the visual decision and the utility are the same decision.

outline vs border: a focus ring that doesn’t jolt the layout

Section titled “outline vs border: a focus ring that doesn’t jolt the layout”

So far every surface we’ve drawn sits still. But there’s one decoration that 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, and the obvious instinct is to draw it with one. That instinct produces a bug, and the reason why is worth understanding.

Here’s the problem. A border occupies layout space, since it’s part of the box’s size. So if you add a 2px border to an element only when it’s focused, the element grows by 2px on every side the instant focus lands on it, and every neighbor shifts to make room. Reflow ripples out from the focused element. Tab through a form drawn this way and the whole layout twitches at every step.

outline doesn’t do that. An outline is drawn on top of the layout, outside the box, taking up no space, so it can appear and vanish without moving a single pixel of anything else. That is the entire reason focus rings are outlines and not borders. It’s geometry, not aesthetics.

The comparison below makes it visible. The first tab fakes the focus ring with a border; the second with an outline. Watch the neighbors.

The bordered button grew, so its neighbors had to move and its top breaks above the guide line. Every focus would jolt the layout.

Two more pieces complete the picture: when the ring shows, and what color it is.

A focus ring has to be visible, because a keyboard user who can’t see where they are is stranded; that’s a hard accessibility requirement, not a nicety. But it should show only on keyboard focus, not when you click with a mouse, because a ring flashing on every click is noise. That’s what the focus-visible: variant is for. Always write the ring through that variant, never as a bare outline on the element, which would show it on every interaction. The canonical form:

<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 doesn’t hug the edge, giving you a clean gap instead of a tight tracing. outline-ring is the themable ring-color token, so the ring recolors with your theme like everything else.

We’re writing focus-visible: here without explaining exactly what it matches beyond “keyboard focus”; the pseudo-class that powers it is the next lesson’s job. For now, treat it as the keyboard-focus ring that you write as a variant, never bare.

ring-*: the shorthand shadcn actually ships

Section titled “ring-*: the shorthand shadcn actually ships”

There’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. Why ship this instead of a plain outline? Three reasons. It follows rounded corners cleanly, so the ring curves around a rounded-lg button instead of cutting square. The gap color is themable through ring-offset-background, so the halo’s inner gap matches whatever surface the element sits on. And ring-ring/50 uses the alpha syntax you know, a half-opacity ring color that’s softer than a solid line.

One mechanical fact is worth knowing so it doesn’t surprise you later: ring-* is implemented as box-shadow layers under the hood. It’s not a real outline; it’s a stack of shadows shaped like a ring. That’s how it gets the offset and follows the rounded corners, and it’s also why combining a heavy shadow-* and a ring-* on the same element occasionally needs care: they’re sharing the same shadow channel.

The habit to carry out of this section is simple: every interactive element, whether button, link, input, or select, gets a focus-visible: ring, written as a variant, never as a bare outline.

You now have every utility this lesson teaches. Let’s put them in a single, realistic className, the kind you’ll actually read in a component, and walk it 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 in the app shares this curve, which is 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 surface tokens, so the card carries the right background and a readable foreground in both light and dark themes. These are the paired tokens from the color lesson.

<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 the ladder, with a transition so it eases instead of snapping. (Motion and the hover: pseudo-class are later lessons; here it’s just the lift.)

<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.

1 / 1

That string is the payoff: radius, hairline, surface, resting elevation, hover lift, and focus ring, every decision from this lesson in one place, with each part doing exactly one job. When you read a shadcn component and see a wall of classes like this, you’re now reading a sentence in the elevation language, not a pile of utilities.

drop-shadow vs box-shadow: a shadow that follows the shape

Section titled “drop-shadow vs box-shadow: a shadow that follows the shape”

Everything so far has used box-shadow, and for rectangles that’s correct. But there’s a second shadow primitive, and the difference between them 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. For a card or a button, which is a rectangle, that’s exactly right.

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 shadow shaped like a clumsy rectangle with box-shadow.

The pair below puts the same transparent shape under each primitive so you can see the difference directly.

box-shadow shadows the rectangular box — wrong for this shape
drop-shadow shadows the rendered silhouette — right
The same transparent star under each primitive. `box-shadow` shadows the rectangular bounding box (the dashed guide), so it spills a rectangle that doesn't match the shape; `drop-shadow` is a filter, so it shadows the rendered silhouette and traces the star.

So the decision is simple. Reach for box-shadow by default, since almost everything is rectangular and it covers those cases. Reach for drop-shadow only when the shape is irregular and a rectangular shadow would look broken: icons, transparent logos, cut-out images.

Two real costs keep drop-shadow from being a free default. First, it’s a filter, and filters create a stacking context (the same way transform does, which you saw back in the layout chapter). Second, it’s more expensive to paint than box-shadow, because the browser has to shadow an arbitrary shape rather than a rectangle. Neither cost is a reason to avoid it; they’re reasons to reach for it when the shape demands it rather than by default.

One last technique, and it’s a specific and common one. You’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.

Here’s the distinction 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 the element’s text perfectly sharp and blurs only what shows through it 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 (that’s the alpha syntax again). backdrop-blur frosts whatever shows through. Together, page content scrolls under the header and gets blurred and softened 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 is the one that does the work. In a 2026 SaaS, this sticky header is the place glass-morphism earns its weight, rather than as a decorative effect sprinkled everywhere.

The mock below shows the effect: content scrolling under a frosted bar.

Acme bg-background/70 backdrop-blur
Dashboard
Revenue
Active users
Notifications
Reports
Settings
scroll ↑ — watch the bands blur under the bar
A sticky `bg-background/70 backdrop-blur` header. Scroll the strip and the colorful bands pass under the bar, blurring and softening, while the bar's own label stays crisp. The one place glass-morphism earns its keep.

The costs are real, so it’s worth budgeting for them:

Now write the whole surface yourself. The exercise below gives you a bare card and a finished target, and your job is to 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.

Target
Your output LIVE

A quick note on that exercise: the target leans on 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, sort out the shadow decision. For each item below, decide which shadow primitive it should use.

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.

box-shadow Rectangular surfaces
drop-shadow Irregular / transparent shapes
A card
A dialog
A button
A dropdown menu
An SVG star icon
A transparent logo (PNG with cut-outs)
A triangular badge

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?

Every neighbor twitches sideways as focus lands, because the focused button is now 4px wider than its unfocused siblings.
The ring renders fine in light mode but disappears in dark mode, because a border color can’t read from a theme token.
The ring traces a square even on a rounded-lg button, because a border can’t follow rounded corners.
The button’s own label goes soft and blurry while the ring is showing.

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-sm
shadow-2xl
shadow-none
inset-shadow-sm

You can now read a wall of utility classes on a shadcn card and see what it’s actually saying: this surface sits at this tier, with this curve, separated by this hairline, ringed this way on focus. Borders, radius, and shadows have stopped being decoration and become a language with a grammar: one ordered scale, one or two radii, one step of elevation per surface tier.

But everything you dressed here was a surface at rest. The focus ring was the one hint of something more, a style that switches on only when the user interacts. You wrote focus-visible: and hover: at the call site and trusted them. Next, you’ll learn what’s actually 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.