Borders, border-radius, outline and box-shadow: Edges, Shadows and the Focus Ring
You show a selected or hover state with color, an inset box-shadow or an outline instead of a thicker border, and you draw the focus ring with :focus-visible and outline, because box-shadow disappears in forced colors mode. On Bookstore's checkout and catalog we measure a border pushing content, the border shorthand wiping border-image, 50% versus 9999px pills, border-image ignoring border-radius, and a shadow vanishing in forced colors.
Version note: This post follows the CSS Backgrounds and Borders Level 3/4 and CSS Basic User Interface Level 4 specifications and the November 2026 state of Baseline (web-features 3.46.0); the examples were run in Chromium 158 and WebKit 27.2 (Playwright 1.65), and Firefox support comes from web-features and MDN browser-compat-data 8.1.9.
Guess First
We are adding a shipping choice to the checkout page of Bookstore, the online bookstore used as the shared example across this site's series. Each option is a card; we tried three ways of marking the selected card: a thicker border, an inner shadow and an outline.
.option { inline-size: 240px; padding: 12px; border: 1px solid #d1d5db; border-radius: 12px; }
.option.thick-border { border-width: 3px; }
.option.inset { box-shadow: inset 0 0 0 3px #1d4ed8; }
.option.outlined { outline: 3px solid #1d4ed8; }
The four cards contain the same text. In which ones does the text move, and in which ones does the card's size change? Write your answer down; the first measurement is about it.
The border Shorthand, Per-Side Longhands and border-style
border sets the width, style and color of all four sides at once; there are also side shorthands such as border-top and longhands such as border-top-width. The logical counterparts (border-inline-start, border-block-end) follow the writing direction; the details are in post 03 of the CSS3 series. The first measurement in the lab:
node scripts/05-edges.mjs
plain box 266x50 text at x=13, y=16
thick box 270x54 text at x=15, y=18
inset box 266x50 text at x=13, y=16
outlined box 266x50 text at x=13, y=16
The answer to the opening question: only in the card with the thick border do both the box (from 266×50 to 270×54) and the text (x from 13 to 15) change. The border is a layer of the box model, and going from 1px to 3px takes 2px more room on each side. The inset shadow and the outline do not enter the box's size at all: both measure the same as the plain card. Changing the border width on a state change (selection, hover, error) therefore pushes the neighboring content; when a card in a list is selected, everything below it moves down by 4px.
border-style has ten values: none, hidden, solid, dashed, dotted, double, groove, ridge, inset and outset. The initial value is none, so border-width: 2px alone draws nothing; without a style the width computes to 0. If border-color is not written, currentColor is used.
The border shorthand follows the reset rule too, with a surprise: it also resets border-image, which it cannot set itself.
.frame { border: 4px solid; border-image: linear-gradient(90deg, #b91c1c, #1d4ed8) 1; }
.frame.reset { border: 4px solid #1f2937; }
frame border-image-source: linear-gradient(90deg, rgb(185
frame-reset border-image-source: none
The second rule only wanted to change the border's color, but the gradient border disappeared completely. If only the color changes, write border-color.
border-radius: Percentages, Elliptical Corners and Pills
border-radius takes a radius for each corner; four values start at the top left and go clockwise. Each corner is actually a quarter of an ellipse: values before the / are horizontal radii, values after it vertical radii (border-radius: 40px / 20px). That is also why percentages are calculated separately on the two axes: the horizontal radius is a percentage of the width, the vertical one of the height. You see two spellings for a pill-shaped button; I measured the difference on a 200×40 box:
.pill { inline-size: 200px; block-size: 40px; background: #1d4ed8; }
.pill.percent { border-radius: 50%; }
.pill.length { border-radius: 9999px; }
percent radius 50% pixel (20, 2): rgb(255, 255, 255)
length radius 9999px pixel (20, 2): rgb(29, 78, 216)
The point 20 pixels from the left and 2 from the top is not painted (white) with 50% and painted (blue) with 9999px. 50% produces an ellipse with a 100px horizontal and a 20px vertical radius, and the box turns into an egg pointed at both ends. With 9999px, when the radii of two corners add up to more than the side, they are scaled down proportionally to 20px half circles; that is the real pill shape. Use a large length for pill buttons, because it stays correct when the box's size changes.
The corner shape itself is about to change as well: the corner-shape property draws cut or soft square corners instead of round ones, with values such as bevel, notch and squircle. It has been in Chrome since 139; according to browser-compat-data it is coming with Safari 27.3, and in Firefox it exists only in development builds. Playwright's WebKit 27.2 build already accepts the value, but that is not the released Safari. It is not Baseline; browsers without support draw the normal round corner, so it can be tried as progressive enhancement.
Gradient Borders: border-image or background-clip?
In the catalog we want a red-to-blue border on the "book of the week" card. The first thing that comes to mind is border-image, but it has a problem on a card with rounded corners:
.grad { inline-size: 160px; block-size: 60px; border-radius: 16px; }
.grad.image { border: 4px solid; border-image: linear-gradient(135deg, #b91c1c, #1d4ed8) 1; }
.grad.clip {
border: 4px solid transparent;
background:
linear-gradient(#ffffff, #ffffff) padding-box,
linear-gradient(135deg, #b91c1c, #1d4ed8) border-box;
}
grad-image corner (1, 1): rgb(184, 28, 28) top border (80, 1): rgb(132, 45, 92)
grad-clip corner (1, 1): rgb(255, 255, 255) top border (80, 1): rgb(131, 45, 93)
In the middle of the border both methods give the same color. At the corner, however, the box with border-image is red: border-image ignores border-radius and draws square corners. The second method uses two background layers: on top a plain white layer painted up to the padding-box, below it the gradient painted up to the border-box. The border is transparent, so the gradient behind it shows through, and both layers are clipped to the border-radius; the corner point is white. This is a direct application of background-clip and multiple layers from the previous post. The place for border-image is textured or 9-slice image borders on boxes with straight corners.
outline, outline-offset and the Focus Ring
An outline looks like a border but is not part of the box model: it takes no room, does not push neighbors and is drawn outside the box. outline-offset moves it away from the box; a negative value pulls it inside. Outlines used to be drawn as rectangles; since March 2023, with Safari 16.4, all three engines follow border-radius. I looked at a corner point and an edge point of the outline around a box with 12px rounded corners:
.ring { inline-size: 120px; block-size: 60px; border-radius: 12px; background: #e5e7eb;
outline: 3px solid #b91c1c; outline-offset: 4px; }
outline corner (-6, -6): rgb(255, 255, 255) edge (60, -6): rgb(185, 28, 28)
The edge point is red and the corner point white: at the corner the outline follows the box's rounding. The outline's most important role for keyboard users is the focus ring. The :focus-visible pseudo-class selects the cases where the browser decides "this focus should be shown": every focus that arrives by keyboard and every focus on a field where text is typed. On a button clicked with the mouse it usually does not match. I measured it in the lab:
.field:focus-visible { outline: 3px solid #1d4ed8; outline-offset: 2px; }
.no-ring:focus { outline: none; }
.shadow-ring:focus-visible { outline: none; box-shadow: 0 0 0 3px #1d4ed8; }
.transparent-ring:focus-visible { outline: 3px solid transparent; box-shadow: 0 0 0 3px #1d4ed8; }
--- chromium
input after Tab: :focus=true :focus-visible=true outline=solid
button after click: :focus=true :focus-visible=false outline=none
--- webkit
input after Tab: :focus=true :focus-visible=true outline=solid
button after click: :focus=false :focus-visible=false outline=none
On the input reached with Tab, :focus-visible matches and the outline is drawn. On the clicked button Chromium gives focus but :focus-visible does not match; a mouse user sees no ring on the button they clicked. WebKit follows macOS behavior and does not give focus to a clicked button at all. The selector itself is in post 02 of the CSS3 series; the rules of focus management are the subject of the Accessibility (a11y) series.
Code that draws the ring with box-shadow instead of an outline is common, because the shadow has always followed border-radius while the outline did not until recently. That has a price. In forced colors mode, such as Windows' high contrast themes, the browser maps the page's colors to the user's palette and removes box-shadow completely:
--- chromium, forced colors
shadow-ring box-shadow: none outline: none rgba(5, 0, 73, 0.8)
transparent-ring box-shadow: none outline: solid rgba(5, 0, 73, 0.8)
The ring drawn only with a shadow (shadow-ring) disappears completely in forced colors: the shadow is none, the outline none. In the field where we wrote outline: 3px solid transparent next to the same shadow, the outline stays and its color is no longer transparent; forced colors mode draws the outline with a color from its own palette. In normal mode the transparent outline is invisible and the shadow shows; in forced colors the shadow goes and the outline shows. In Bookstore's base styles we use an outline directly:
:focus-visible {
/* transparent outlines turn visible in forced colors mode */
outline: 3px solid var(--color-focus);
outline-offset: 2px;
}
box-shadow: Offset, Blur, Spread, Inset and an Elevation Scale
box-shadow consists of a horizontal offset, a vertical offset, blur, spread and a color, in that order; with inset in front it falls inside the box. Spread grows the shadow in every direction (a negative value shrinks it); a shadow with 0 blur and a positive spread draws a solid ring around the box. Several shadows can be written separated by commas, and the first one is painted on top:
card box-shadow: rgba(0, 0, 0, 0.08) 0px 1px 2px 0px, rgba(0, 0, 0, 0.12) 0px 4px 12px 0px
In the computed value the color moves to the front and a spread of 0 is added. For a realistic shadow two layers work well: a tight, slightly dark contact shadow right under the box, and a soft ambient shadow that spreads further. In Bookstore we turn these pairs into a three-level elevation scale kept in custom properties; the card normally gets sm and lg on hover:
:root {
--color-focus: #1d4ed8;
/* elevation scale: a tight contact shadow plus a soft ambient one */
--shadow-sm: 0 1px 2px rgb(15 23 42 / 0.08), 0 1px 3px rgb(15 23 42 / 0.06);
--shadow-md: 0 2px 4px rgb(15 23 42 / 0.08), 0 4px 12px rgb(15 23 42 / 0.10);
--shadow-lg: 0 4px 8px rgb(15 23 42 / 0.08), 0 12px 32px rgb(15 23 42 / 0.14);
}
.book-card {
padding: 1rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 0.5rem;
box-shadow: var(--shadow-sm);
}
.book-card:hover { box-shadow: var(--shadow-lg); }
/* gradient border that follows border-radius */
.book-card.featured {
border: 2px solid transparent;
background:
linear-gradient(var(--color-surface), var(--color-surface)) padding-box,
linear-gradient(135deg, var(--color-accent), var(--color-link)) border-box;
}
node scripts/05-pages.mjs
--- chromium
express selected: box-shadow rgb(29, 78, 216) 0px 0px 0px 1px inset
price moved: false
#email after Tab: :focus-visible=true outline 3px solid rgb(29, 78, 216), offset 2px
featured card corner pixel: rgb(249, 250, 251) (page background is rgb(249, 250, 251))
card shadow: rgba(15, 23, 42, 0.08) 0px 1px 2px 0px, rgba(15, 23, 42, 0.06) 0px 1px 3px 0px
card shadow hover: rgba(15, 23, 42, 0.08) 0px 4px 8px 0px, rgba(15, 23, 42, 0.14) 0px 12px 32px 0px
box moved on hover: false
--- webkit
express selected: box-shadow rgb(29, 78, 216) 0px 0px 0px 1px inset
price moved: false
#email after Tab: :focus-visible=true outline 3px solid rgb(29, 78, 216), offset 2px
featured card corner pixel: rgb(249, 250, 251) (page background is rgb(249, 250, 251))
card shadow: rgba(15, 23, 42, 0.08) 0px 1px 2px 0px, rgba(15, 23, 42, 0.06) 0px 1px 3px 0px
card shadow hover: rgba(15, 23, 42, 0.08) 0px 4px 8px 0px, rgba(15, 23, 42, 0.14) 0px 12px 32px 0px
box moved on hover: false
--- chromium, forced colors
selected option: box-shadow none, border-color rgb(0, 0, 0)
unselected option: box-shadow none, border-color rgb(0, 0, 0)
#email focus outline: solid 3px
On hover the shadow changes but the card's box does not move; like the outline, the shadow does not enter the layout. The top left corner pixel of the featured card equals the page background, so the gradient border is rounded at the corner. Using the interface's dark navy (rgb(15 23 42)) instead of pure black for the shadow color makes the shadow sit more naturally on the background. If you need a shadow that follows the edge of a shape (for example the shape of a cover image with a transparent background), use filter: drop-shadow() instead of box-shadow; that is the subject of this series' filter post.
Shipping Choice: An inset Shadow Instead of a Border
On the checkout page we apply the lesson from the opening question. Each shipping option is a label; when the radio inside it is selected, :has(:checked) switches the card to the selected look:
<fieldset class="shipping-options">
<legend>Shipping</legend>
<label class="shipping-option">
<input type="radio" name="shipping" value="standard" checked>
Standard (3-5 days) <span class="cost">₺49.90</span>
</label>
<label class="shipping-option">
<input type="radio" name="shipping" value="express">
Express (next day) <span class="cost">₺89.90</span>
</label>
</fieldset>
.shipping-option {
display: flex;
gap: 0.75rem;
align-items: center;
padding: 0.75rem 1rem;
border: 1px solid var(--color-border);
border-radius: 0.75rem;
background: var(--color-surface);
}
/* selected: border color + 1px inset shadow = a 2px ring, no layout shift */
.shipping-option:has(:checked) {
border-color: var(--color-link);
box-shadow: inset 0 0 0 1px var(--color-link);
}
On the selected card the border width does not change; only its color changes and a 1px shadow is added inside. The visible effect is a 2px ring, but the box keeps its size. In the measurement above, the price text did not move at all when Express was selected, and when the e-mail field was reached with Tab, the 3px outline was drawn with a 2px offset.
The forced colors lines say something here too: the selected card's shadow turns into none and the border color of both cards is mapped to the same system color (black here). So in forced colors the only thing that distinguishes the selected card from the others is the radio itself. Since we do not hide the native radio, that is enough. Had we customized the radio completely with appearance: none, we would need a visible marker for the selected state (an icon, or an outline that works in forced colors); form controls are covered in a later form post of this series.
In Production
Animating box-shadow directly means the shadow is repainted on every frame; with large blur values this is expensive. If you want a soft lift on hover, putting the large shadow on an ::after pseudo-element and animating only its opacity runs on the compositor. The measurement of that technique is in post 13 of the CSS3 series. Here we change the shadow without animation.
Check border, outline and shadow decisions in three modes: normal, dark mode and forced colors. Reading computed values as in this post, with Playwright's forcedColors: 'active' and colorScheme: 'dark' options, catches an indicator that relies on a shadow disappearing early. Shadows are hard to see in dark mode; on a dark background, elevation needs to come from a lighter surface color. The theme tokens themselves are in post 10 of the CSS3 series.
Common Pitfalls
First, writing outline: none without putting anything in its place. Keyboard users cannot see where they are. If you do not like the default ring, write your own outline with :focus-visible; do not remove it entirely.
Second, changing the border width in a hover or selected state. As we measured, the box grows and the content moves. Keep the width fixed and change the color; if you need extra thickness, use an inset shadow or an outline.
Third, drawing the focus ring only with box-shadow. In forced colors the shadow is removed and the ring disappears; add a transparent outline next to it or use an outline directly.
Fourth, using border-image for a gradient border on a card with rounded corners. The corners stay square; use the two-layer background-clip method.
Baseline Status
node scripts/baseline.mjs borders border-radius border-image outline box-shadow focus-visible has corner-shape forced-colors
borders widely 2018-01-29 Chrome 1, Firefox 1, Safari 1, iOS 3
border-radius widely 2018-01-29 Chrome 4, Firefox 4, Safari 5, iOS 4.2
border-image widely 2019-08-01 Chrome 56, Firefox 50, Safari 9.1, iOS 9.3
outline widely 2025-09-27 Chrome 94, Firefox 88, Safari 16.4, iOS 16.4
box-shadow widely 2018-01-29 Chrome 10, Firefox 4, Safari 5.1, iOS 5
focus-visible widely 2024-09-14 Chrome 86, Firefox 85, Safari 15.4, iOS 15.4
has widely 2026-06-19 Chrome 105, Firefox 121, Safari 15.4, iOS 15.4
corner-shape not Baseline Chrome 139, Firefox -, Safari -, iOS -
forced-colors widely 2025-03-12 Chrome 89, Firefox 89, Safari 16, iOS 16
| Feature | Status | Note |
|---|---|---|
border, border-radius, box-shadow | Widely available | The shorthand resets border-image too |
border-image | Widely available | Ignores border-radius |
outline (following rounded corners) | Widely available | Newly in 2023-03, widely in 2025-09 |
:focus-visible | Widely available | Usually does not match a clicked button |
:has() | Widely available | Since 2026-06 |
forced-colors media feature | Widely available | Shadows are removed in this mode |
corner-shape | Not Baseline | Chrome 139; coming with Safari 27.3 |
A Small Experiment
★☆☆ Turn the lab's .option.thick-border into a rule that gives the same look without changing the width, and confirm with 05-edges.mjs that the box stays at 266×50.
★★☆ Give the "Pay now" button on the checkout page a :focus-visible ring that follows its pill shape and sits 3px away from it; measure with a line you add to 05-pages.mjs that this outline stays in forced colors mode.
★★★ Build the card's hover shadow with an ::after pseudo-element and opacity; show with computed values that in the normal and hover states the card's own box-shadow does not change and only the pseudo-element's opacity does.
In the next post we move to visual effects: the translate, rotate and scale functions and the individual transform properties, how the order changes the result, transform-origin, and a 3D card that flips between the front and back cover on the book detail page.