# WebCraft > WebCraft publishes small interface elements one at a time: each is rendered live in the page, shown with the source you can take, and carries two notes — the caveat that breaks it, and what happens in an engine that does not support it. An element with no real caveat does not get published. > Note on this domain: web-craft.net previously hosted a Japanese web production business (2003-2016) and then an unrelated Australian freelance design practice trading as WebCraft (2021-2026). This publication is neither of them and claims nothing of theirs. Full disclosure at https://web-craft.net/about/. ## Elements - [Hover that stays off touchscreens](https://web-craft.net/elements/hover-that-stays-off-touchscreens/): `@media (hover: hover)`. The query tests the primary pointer only. A laptop with a touchscreen usually matches, since the trackpad counts as primary, and a finger tapping that same screen still latches :hover until the next tap. any-hover matches when any connected pointer can hover, which is usually too broad for styling. Test the primary pointer and accept that hybrid machines get the mouse version. - [The filled half of a range track](https://web-craft.net/elements/filled-half-of-a-range-track/): `::-moz-range-progress`. Only Firefox has a pseudo-element for the part of the track behind the thumb. In Chromium and Safari the fill is a trick: a wide box-shadow thrown left from the thumb and cut off by overflow: hidden on the input. That overflow clips everything outside the input's box, so the thumb can never stand taller than the track. A big round thumb on a hairline track needs one line of script instead, writing the value into a custom property that a gradient reads. - [Numbering the browser keeps for you](https://web-craft.net/elements/numbering-the-browser-keeps-for-you/): `counters()`. counter-reset creates a fresh counter scoped to the element, even when one with that name already exists above it. That is why the nested list above counts 2.1, 2.2 instead of 3, 4, and why a reset halfway down a list starts a nested copy where you expected the count to restart. Hiding changes the count as well: an item with display: none is skipped, one with visibility: hidden still counts, so two ways of hiding a step give two different numberings. The accessibility catch is in the markup. Numbers drawn by ::before are paint, and list-style: none makes VoiceOver stop announcing the list as a list, so keep the
    and add role="list" back if you strip its style. - [An underline you actually drew](https://web-craft.net/elements/underline-you-actually-drew/): `text-underline-offset`. Two traps hide in plain sight. First, text-decoration does not inherit — it propagates: drawn on the parent, painted straight through every child, and no child can undo it, because text-decoration: none on the span removes decorations the span itself owns, not the line passing through from above. Second, the shorthand resets its own longhands: writing text-decoration: underline after the tuning quietly puts thickness back to auto — offset survives only because it lives outside the shorthand — so tune after the shorthand or skip it. Size the tuning in em so it scales with the type, and know that skip-ink defaults to skipping — right for Latin descenders, wrong for scripts like Chinese where the gaps read as damage, which is what none is for. - [Type on rails, not on stops](https://web-craft.net/elements/type-on-rails-not-on-stops/): `clamp()`. The middle term is where fluid type quietly fails an audit: a preferred value written purely in viewport units does not respond when someone raises their browser’s text size, because vw tracks the window, not the reader’s setting — and text that cannot reach 200% is a WCAG failure, not a taste issue. Mix a rem term into the middle — clamp(1.4rem, 1rem + 2.5vw, 2.6rem) — and zoom moves the whole expression again. Two smaller teeth: if the floor ends up larger than the ceiling, the floor wins, which is how a refactored token turns every headline huge; and inside the calculation a bare 0 needs a unit, because clamp(0, …) is not a length and the declaration dies whole. - [The fixed element that stopped meaning it](https://web-craft.net/elements/fixed-element-that-stopped-meaning-it/): `fixed under transform`. position: fixed is pinned to the viewport only while no ancestor gets in the way, and the list of things that get in the way keeps growing: transform, filter, backdrop-filter, will-change: transform, contain: paint, container-type. Any of them turns the ancestor into the containing block, and the fixed element quietly behaves as absolute inside it. This is why the sticky toolbar dies the day someone adds a slide-in animation to the panel around it, and why a third-party wrapper with a stray translateZ(0) breaks a modal it has never heard of. The fix is structural, not stylistic: move the fixed element outside the transformed subtree, or drop the transform once the animation ends. - [A shadow that knows the shape](https://web-craft.net/elements/shadow-that-knows-the-shape/): `filter: drop-shadow()`. drop-shadow() exists because box-shadow shades the box: a star, a speech-bubble tail or a transparent PNG casts a rectangle, or — under clip-path — nothing at all, since the shadow is part of the element’s own paint and the clip cuts it off with everything else. The trap is assuming the filter escapes the same knife. It does not: on one element the clip is applied to the filtered result, so the left star’s shadow dies exactly like a box-shadow would. The pattern that works is one element up — clip the child, filter the parent, and the parent shades whatever shape survived. Smaller print: no spread argument, no inset, the same blur number paints a visibly softer and wider halo than box-shadow’s — retune the value, do not copy it — and a filter makes the element a containing block, so a position: fixed descendant stops meaning the viewport. - [The long word that will not bend](https://web-craft.net/elements/long-word-that-will-not-bend/): `overflow-wrap: anywhere`. Three properties fight over this and only one of them is usually right. overflow-wrap: anywhere and its older sibling break-word paint identically in a fixed box — both break the long token only when it has nowhere else to go — but they differ where nobody looks: anywhere lets the breaks count towards min-content size, so inside a flex or grid track the column can actually get narrow, where break-word holds the track at the full unbroken word and the “fix” changes nothing. word-break: break-all is the blunt instrument — it breaks mid-word everywhere, prose included; it exists for CJK text, not for comments with links in them. None of the three hyphenate: a break is a cut, and the hyphens live in a different property with a lang requirement. - [An ellipsis is three declarations, not one](https://web-craft.net/elements/ellipsis-is-three-declarations-not-one/): `text-overflow: ellipsis`. text-overflow: ellipsis is a passenger: on its own it does nothing, because it only describes how to paint text that is already being clipped. The clipping takes overflow: hidden and the single line takes white-space: nowrap — lose either and the name above just walks out of its box. In a flex or grid row there is a fourth wall: the item’s automatic minimum size refuses to shrink below the text, so the box grows instead of truncating — and conveniently, the same overflow: hidden that enables the ellipsis also zeroes that minimum. One declaration, both jobs. It is strictly one line; for two or three, that is -webkit-line-clamp, a different element on this site. - [Less motion is not no motion](https://web-craft.net/elements/less-motion-is-not-no-motion/): `prefers-reduced-motion`. The query reports a vestibular accessibility setting, not a taste in animation — what triggers the symptoms is movement: spins, parallax, zooms, things travelling across the screen. An opacity crossfade is almost always safe to keep, which is why the word in the query is reduced. The blanket animation: none !important has a trap of its own: an animation that never runs never reaches its end state and never fires animationend, so the element that was going to fade in stays invisible and the listener waits forever. The safer blanket shrinks every duration to a frame — the end state and the event still land, only the journey is gone. - [A layout drawn as a picture of itself](https://web-craft.net/elements/layout-drawn-as-a-picture-of-itself/): `grid-template-areas`. The ASCII picture is the strictest syntax on this site: every row string must name the same number of columns, and every name must add up to a rectangle. One typo, one row a cell short, one L-shaped area — and the entire declaration is invalid, silently, so the grid falls back to auto placement and the layout collapses with no error anywhere. There is no patching it per breakpoint either: a media query has to restate the whole picture, because the map replaces, it never merges — and grid-template-columns has to move with it, or the old second track survives the breakpoint, sits there empty, and the whole layout quietly lives in half its width. A lone dot names a cell nothing owns. - [Four faint shadows beat one dark one](https://web-craft.net/elements/four-faint-shadows-beat-one-dark-one/): `box-shadow ×4`. A single shadow big enough to say “floating” is also dark enough to say “smudge”, because real light does not stop at one penumbra. A ladder of three or four layers, each roughly doubling the offset and blur of the one before at a constant low alpha, reads as a single soft shadow with a dense core and a wide falloff. The alphas stack where the layers overlap — four times .07 is about .25 against the element, thinning to .07 at the rim — so judge the total, not the layer. And keep box-shadow out of transitions: it repaints every frame of the animation. Put the hover shadow on an ::after and transition that pseudo’s opacity instead. - [Five colours out of one token](https://web-craft.net/elements/five-colours-out-of-one-token/): `oklch(from …)`. The channel names are not variables you set — they are the source colour taken apart. l, c and h arrive already holding the token’s own values, and calc() on them is the whole trick. Do the arithmetic in oklch even when the token is an sRGB hex: a lightness nudge there is perceptually even, where the same nudge through rgb() lands differently on a blue than on a yellow. Hue wraps on its own, so h + 180 is the complement with no modulo — but lightness and chroma clamp silently: calc(l + .3) on an already-light token quietly pins at white, and a palette built on additions looks fine right up until someone feeds it a token near the edge. Multiply where the token is not yours to predict. - [Hidden from eyes, kept for readers](https://web-craft.net/elements/hidden-from-eyes-kept-for-readers/): `.visually-hidden`. The obvious hiding tools take the element out of the accessibility tree with them: display: none and visibility: hidden silence a screen reader as thoroughly as they blank the screen, and a width: 0 box gets skipped by some readers too. What survives is a box hidden by geometry rather than removal — one pixel square, clipped, overflow cut. The declaration nobody reads is doing real work: without white-space: nowrap the 1px box wraps every word onto its own line, and some readers then announce the label one word at a time. And keep focusable elements out of it unless the disappearing act is deliberate — a link in there is a Tab stop on something the eye cannot find. - [The link only keyboards ever meet](https://web-craft.net/elements/link-only-keyboards-ever-meet/): `skip link + :focus`. The two natural ways to hide something — display: none and visibility: hidden — are exactly the ways that make an element unfocusable, and an unfocusable skip link can never receive the Tab press that is supposed to reveal it. Park it off-screen with a transform instead and bring it back on :focus. It has to be the first focusable thing in the document, before the logo link, or the keyboard walks the whole header first and the link skips nothing. And the target wants tabindex="-1": the id alone scrolls the page, but in several browsers sequential focus stays where it was, so the next Tab lands right back in the navigation that was just skipped. - [The same two colours, mixed two ways](https://web-craft.net/elements/same-two-colours-mixed-two-ways/): `color-mix()`. The colour space is required, and it decides the answer: srgb runs the midpoint through grey, which is why a derived hover tint so often looks washed out compared to the swatch the designer picked. Then the percentages, which are the part nobody reads: they are normalised rather than rejected, so 30% and 30% is a 50/50 mix — but because they add up to less than 100%, the result also comes back at 60% alpha. A colour that is both the wrong ratio and quietly translucent, from a line that looks arithmetically fine. currentColor is allowed inside, which is what lets one rule tint every state of a component that has not picked its colour yet. - [Why the last row stretches, and how to stop it](https://web-craft.net/elements/why-the-last-row-stretches-and-how-to-stop-it/): `auto-fit vs auto-fill`. The two keywords are identical until the items run out, which is why the difference never shows up in the design and always shows up in production: auto-fit collapses the empty tracks, so three cards in a six-track row grow to a third of the width each, and auto-fill keeps them, so three cards stay card-sized and the rest of the row is blank. Neither is the right answer on its own. Separately, 1fr is minmax(auto, 1fr) and that auto floors at min-content, so one long URL or a
     pushes its track past its share and the whole grid overflows sideways — minmax(0, 1fr) is the fix and costs nothing.
    - [Centring with no flex and no grid](https://web-craft.net/elements/centring-with-no-flex-and-no-grid/): `align-content (block)`. It distributes free space, so a box with none — an auto height, which is most boxes — accepts the declaration and does nothing with it. The height or min-height is the load-bearing half, and leaving it out is the whole of "align-content does not work in block layout". The other half is that only this property crossed over: align-items and justify-content are still inert in block layout, and reaching for them is the usual first guess. Children stay in the block flow while this happens, so their margins still collapse into each other and shift the centre by an amount you did not write.
    - [An edge that fades instead of cutting](https://web-craft.net/elements/edge-that-fades-instead-of-cutting/): `mask-composite: intersect`. Two edges need two gradients, and the default mask-composite: add unions them — the union of "everything but the left" and "everything but the right" is everything, so both fades disappear and it reads as the property having done nothing. intersect is the whole fix. Then watch the shorthand: mask resets every longhand it does not name, mask-composite included, so a shorthand written after it silently throws the second gradient away. And a mask clips the element's entire painting, so the focus ring on the last chip fades out with the chip while the chip stays tabbable — invisible and fully interactive at the same time.
    - [A vertical label that does not break the box](https://web-craft.net/elements/vertical-label-that-does-not-break-the-box/): `writing-mode: vertical-rl`. writing-mode moves the block axis and every physical property keeps pointing where it always did, so the layout stops agreeing with the names: height now caps how far the label runs down its own line and width sizes the block, which is why the first attempt either overflows the box or wraps after two words. The logical pair — inline-size, block-size, padding-inline, margin-block — are the ones that follow the rotation. Watch text-orientation: upright too: it stands latin letters up one at a time and turns off kerning and ligatures while it does.
    - [A heading that sits on its own cap height](https://web-craft.net/elements/heading-that-sits-on-its-own-cap-height/): `text-box: trim-both`. It trims the block box, not every line: a heading that wraps keeps the full leading between its lines and only loses it above the first and below the last. The amount comes from the first available font's metrics, so a web font that arrives after the fallback drew the first frame trims by a different number and the heading shifts on load — the same jump size-adjust exists to fix. And the edge values are metric names rather than lengths: cap alphabetic cuts to the capital height and the baseline, which is what a display line wants, while text cuts to the font's own ascent and descent and barely moves anything.
    - [Type that inverts itself over anything](https://web-craft.net/elements/type-that-inverts-itself-over-anything/): `mix-blend-mode: difference`. Blending only reaches as far as the nearest ancestor that forms an isolated group, and an opacity below 1, a transform, a filter, will-change or position: fixed quietly creates one — so the text inverts against a backdrop that is not the one on screen, and nothing in the styles says why. isolation: isolate is that same mechanism used on purpose: it pins the blend to a box you chose. The second trap is arithmetic, not support: difference against a mid grey returns another mid grey, so the type vanishes exactly where the backdrop is neither light nor dark.
    - [An outline that does not eat the letters](https://web-craft.net/elements/outline-that-does-not-eat-the-letters/): `paint-order: stroke fill`. -webkit-text-stroke centres the stroke on the glyph outline, so half of a five-pixel stroke grows inward and thins the letterform until the counters in a, e and o close up. paint-order: stroke fill repaints the fill over the stroke and the shape comes back, which is the whole trick and the only fix that does not involve a second copy of the text. Two things to budget for: the stroke takes no space in layout, so it overflows the line box, crowds the line above and is cut by any overflow: hidden ancestor; and the property is still prefixed after fifteen years, with no unprefixed alias on the way.
    - [A drop cap the browser measures for you](https://web-craft.net/elements/drop-cap-the-browser-measures-for-you/): `initial-letter`. initial-letter sizes and sinks the letter vertically and does nothing horizontally, so the text still runs flush against it until you add your own margin-right. Two numbers are also not one: initial-letter: 3 means three lines tall and sunk three, which drops the cap below the first line where you almost certainly wanted 3 2. It applies to ::first-letter in a block container and is ignored anywhere else, including on a flex item.
    - [Stripes that stay right when rows are filtered](https://web-craft.net/elements/stripes-that-stay-right-when-rows-are-filtered/): `:nth-child(of S)`. li:nth-child(odd of :not(.gone)) and li:not(.gone):nth-child(odd) are different selectors, and the second one is the reading most people arrive at. The first numbers within the kept rows, which is what striping wants. The second numbers among all siblings and then throws away the ones that do not match, so hiding two rows leaves two stripes in a row and it looks like a rendering bug rather than a selector you wrote.
    - [One declaration that covers both themes](https://web-craft.net/elements/one-declaration-that-covers-both-themes/): `light-dark()`. light-dark() does not look at the media query, it looks at the computed color-scheme of the element. Leave that unset and it silently returns the first value forever — the second swatch above is the same CSS with color-scheme: normal, and it never changes. A theme that half works is harder to find than one that does not work at all, and this is the usual reason for it.
    - [Carousel dots the browser draws itself](https://web-craft.net/elements/carousel-dots-the-browser-draws-itself/): `::scroll-marker`. A marker only exists once content is set on it. An empty string counts; leaving the property out produces no pseudo-element at all, so the group renders as nothing and the feature reads as unsupported when it is working perfectly. The group also has to be declared on the scroll container itself with scroll-marker-group, not on the items.
    - [A tooltip that follows its anchor with no JavaScript](https://web-craft.net/elements/tooltip-that-follows-its-anchor-with-no-javascript/): `anchor-name + position-area`. Without position-try-fallbacks the tooltip will happily render off the edge of the viewport, and you will not notice, because you are testing in a wide window with the anchor in the middle of it. The fallback list is not a polish step, it is the half of the feature that handles the anchor being near an edge — which is where tooltips actually live.
    - [Styles that stop at a boundary you name](https://web-craft.net/elements/styles-that-stop-at-a-boundary-you-name/): `@scope`. @scope is not encapsulation. It limits where these rules apply and does nothing to stop rules from anywhere else reaching in — a stray p { color: red } in another sheet still wins on specificity and still paints the inner block. Reaching for it expecting shadow-DOM behaviour gets you the opposite of what you expected, quietly. The limit is also exclusive: to (.inner) leaves .inner itself outside the scope, so you cannot style the boundary element from within.
    - [Animating height to auto, finally](https://web-craft.net/elements/animating-height-to-auto-finally/): `interpolate-size`. height: auto becomes animatable only after opting in with interpolate-size: allow-keywords — and the opt-in inherits. Put it on :root, as most examples do, and every intrinsic-size transition on the whole site changes behaviour in one line. Scope it to the component until that is a decision rather than an accident.
    - [A spring in the easing function](https://web-craft.net/elements/spring-in-the-easing-function/): `linear()`. Both knobs leave together and both take 550ms; only the lower one arrives like an object with mass. linear() is not a curve — it is a polyline the engine walks between the stops you hand it, so a spring is a real spring equation sampled into two dozen of them. That is the first thing it costs: nobody writes this by hand, and a stop count low enough to read is low enough to see, as facets in what should be smooth. The second is that it is a timing function over a fixed duration and not physics, so it has no state. Toggle twice quickly and it does not carry its velocity into the new run the way a spring would; it restarts along the curve from wherever it stood, and the overshoot lands somewhere the eye reads as a fault. And the overshoot only exists on properties that can pass their end value: translate and transform can, opacity clamps at 1, so a bounce written on a fade is quietly just a fade.
    - [Type that wraps to a curve, not a box](https://web-craft.net/elements/type-that-wraps-to-a-curve-not-a-box/): `shape-outside`. The lines follow the circle because the float carries a shape — and only because of that. shape-outside applies to floats and to nothing else, so the same declaration on a grid item, a flex item or a positioned box does exactly nothing, silently. What it does not do is change the element it sits on: that box is still a rectangle, its background still paints square, and the circle you see is border-radius doing separate work on the same numbers. Keep the two descriptions in step by hand or the text wraps a curve that is not there. The trap is margin: the shape resolves against the margin box by default, so adding margin: 10px grows the reference box and circle(50%) grows with it — the wrap drifts wider than the painted circle and the gap opens unevenly. Name the box and it stops moving: circle(50%) border-box pins the shape to the circle you drew, which is what lets the −5px margin here hang the disc past the text edge — a round form on the same vertical as flat type always reads a little inset — without dragging the wrap out with it. For a gap that follows the curve, use shape-margin, which is measured from the shape itself.
    - [Reveal on scroll, with no observer](https://web-craft.net/elements/reveal-on-scroll-with-no-observer/): `animation-timeline: view()`. Put the hidden state in the keyframe’s from, never on the element itself. Engines without view() ignore the timeline, the animation runs its zero-second duration and finishes — content simply appears. Hide elements with opacity: 0 in their own styles instead and those engines show a blank page forever. That inversion is the entire safety of this pattern.
    - [Clipping that leaves sticky alone](https://web-craft.net/elements/clipping-that-leaves-sticky-alone/): `overflow: clip`. Scroll both panes: the same heading sticks on the right and scrolls away on the left. overflow-x: hidden is not allowed to stay on its axis — the used value of the other one changes from visible to auto, so the wrapper becomes a scroll container. Every position: sticky below it now resolves against that box, which has no height of its own and therefore never scrolls, so it never sticks. This is what a wrapper added months earlier to stop one horizontal bleed does to a header nobody has touched. clip carries no such coupling: overflow-x: clip leaves overflow-y: visible alone.
    - [A dropdown a keyboard can hold open](https://web-craft.net/elements/dropdown-a-keyboard-can-hold-open/): `:focus-within`. Hover-only menus lock keyboards out; :focus-within keeps the panel open while anything inside it holds focus, so Tab walks straight through. Two traps: the menu snaps shut the instant focus leaves, and a pixel of empty space between trigger and panel breaks the hover path — bridge the gap with padding on the panel, never margin. Hide it with visibility, not display, so the tab from trigger to first item has something to land in.
    - [Avatars that never squash](https://web-craft.net/elements/avatars-that-never-squash/): `object-fit: cover`. The default is fill, which is why the left face is squashed: a wide photo forced into a square stretches. cover crops instead — but it crops from the centre, so a face near the edge of the photo needs object-position to stay in frame. And the box must have both dimensions: with only a width the height follows the image and there is nothing to crop against.
    - [An underline that slides in](https://web-craft.net/elements/underline-that-slides-in/): `background-size`. text-decoration cannot animate its width, so the underline here is a background: a one-colour gradient sized 0% 2px, grown to 100%. The catch is that the percentage resolves against the whole inline box — on a link that wraps, each line fragment shows a slice of one long underline. Keep it for single-line labels and give wrapped prose links a plain text-decoration.
    - [An analog clock wound by one line of JavaScript](https://web-craft.net/elements/analog-clock-wound-by-one-line-of-javascript/): `animation-delay: -43200s`. CSS cannot ask what time it is. The clock is three infinite rotations, and a negative animation-delay winds it: minus four hours starts an animation four hours in, instantly. The only JavaScript writes seconds-since-midnight into a custom property — and that is the whole script. The dial itself is laid out by cos() and sin(), not twelve hand-tuned offsets.
    - [The logo that never hits the corner](https://web-craft.net/elements/logo-that-never-hits-the-corner/): `animation + cqw`. Two animations cannot share a property. Put the horizontal and the vertical drift on one element and the second translate wins outright instead of the two combining — which is why this needs one element per axis. And 100% in a translate is the element’s own size, so the travel is 100cqw - 100%: the box, less the logo.
    - [resize keeps writing after max-width stops it](https://web-craft.net/elements/resize-keeps-writing-after-max-width-stops-it/): `resize + max-width`. Dragging writes an inline width and does not stop at max-width. The box paints clamped, but a fit-content parent measures the stored value instead, so anything centred in that parent drifts off the box it belongs to.
    - [A gradient border that keeps its corners](https://web-craft.net/elements/gradient-border-that-keeps-its-corners/): `background + padding`. border-image ignores border-radius outright — square corners, every time, and no amount of radius fixes it. So the border is not a border here: it is padding over a gradient background.
    - [A spotlight that follows the cursor](https://web-craft.net/elements/spotlight-that-follows-the-cursor/): `radial-gradient at var()`. Gate it behind @media (hover: hover) and (pointer: fine). On a touchscreen :hover latches after a tap, so the light switches on and stays on with nothing hovering it.
    - [A progress bar tied to scroll, with no JavaScript](https://web-craft.net/elements/progress-bar-tied-to-scroll-with-no-javascript/): `animation-timeline: scroll()`. An animation whose timeline is unsupported does not simply switch off — it still applies its fill state. Ship this unguarded and browsers without scroll timelines show a permanently full progress bar.
    - [A table header that stays put](https://web-craft.net/elements/table-header-that-stays-put/): `position: sticky`. Any ancestor carrying overflow: hidden switches sticky off silently — no warning, no error, the header just scrolls away. That ancestor is usually a card wrapper somebody added months later.
    - [Cut a paragraph to three lines](https://web-craft.net/elements/cut-a-paragraph-to-three-lines/): `-webkit-line-clamp`. All four declarations are load-bearing. Bottom padding is the trap: the clipped line stays visible inside the padding box, so pad the parent, never the clamped element.
    - [A textarea that grows as you type](https://web-craft.net/elements/textarea-that-grows-as-you-type/): `field-sizing: content`. Give it a max-height, or a pasted wall of text pushes the submit button off screen — worse than the scrollbar you were removing. lh units keep the bounds in lines, not guessed pixels.
    - [An accordion with no JavaScript](https://web-craft.net/elements/accordion-with-no-javascript/): `
    `. Hiding the marker takes two declarations, not one. Miss either and one browser keeps a triangle you never designed for. - [A focus ring for keyboards only](https://web-craft.net/elements/focus-ring-for-keyboards-only/): `:focus-visible`. :focus { outline: none } is the most common accessibility bug on the web, and it looks like a tidy-up. It takes the ring from keyboard users, the only people who needed it. - [A wide table that keeps its first column](https://web-craft.net/elements/wide-table-that-keeps-its-first-column/): `position: sticky (inline)`. The corner cell is sticky in two directions at once, so it has to outrank both the header row and the pinned column. Give it the highest z-index of the three or the scrolling cells paint straight over it. - [Shadows that show there is more to scroll](https://web-craft.net/elements/shadows-that-show-there-is-more-to-scroll/): `background-attachment: local`. The two cover gradients have to match the container background exactly. Put this on a transparent or patterned surface and the covers read as grey smears sitting on top of your design. - [A segmented control with a sliding indicator](https://web-craft.net/elements/segmented-control-with-a-sliding-indicator/): `:has() + translate`. Never size the indicator in pixels. Hardcode a width and a shift and the pill lines up until someone renames an option — then it sits half over the next label, with no error to tell you. Equal columns and a shift of 100% of itself need no measuring. - [A skeleton that does not lie about the layout](https://web-craft.net/elements/skeleton-that-does-not-lie-about-the-layout/): `@keyframes + gradient`. A skeleton whose blocks are not the size of the real content is worse than a spinner: the page visibly rearranges the moment data lands. Match the line heights and widths you will actually render. - [A menu button that turns into a close button](https://web-craft.net/elements/menu-button-that-turns-into-a-close-button/): `aria-expanded`. It is a button with aria-expanded, not a checkbox. A checkbox announces itself as a checkbox, and a menu toggle that reads as "checkbox, not checked" is worse than no label at all. - [A switch that is still a checkbox](https://web-craft.net/elements/switch-that-is-still-a-checkbox/): `appearance: none`. Style the input itself rather than hiding it behind a div. appearance: none strips the paint and keeps everything else — focus, the space key, the label association, the announced role. - [A marquee that never jumps](https://web-craft.net/elements/marquee-that-never-jumps/): `translateX(-50%)`. The track holds the list twice and travels exactly -50%. Any other distance, or a single copy, and the loop visibly snaps back at the seam. - [Frosted glass over anything](https://web-craft.net/elements/frosted-glass-over-anything/): `backdrop-filter`. The element needs its own translucent background. Fully opaque and there is nothing to see through; fully transparent and most engines skip the filter entirely. Somewhere near 40% is where it reads as glass. - [Type painted with a gradient](https://web-craft.net/elements/type-painted-with-a-gradient/): `background-clip: text`. color: transparent is doing the work, so if the background fails to paint for any reason the text is invisible rather than merely unstyled. Never put a whole paragraph in it, only display type you can afford to lose. - [A range slider you can actually style](https://web-craft.net/elements/range-slider-you-can-actually-style/): `::-webkit-slider-thumb`. The WebKit and Gecko thumb selectors cannot share a rule. One unknown pseudo-element invalidates the entire selector list, so a combined rule silently styles nothing anywhere. Write them out separately, every time. - [A card that tips towards the cursor](https://web-craft.net/elements/card-that-tips-towards-the-cursor/): `perspective + rotate`. The perspective belongs on the parent, not on the card. Put it on the transformed element and every card gets its own vanishing point, so a row of them tilts in visibly different directions. - [A gradient angle that actually animates](https://web-craft.net/elements/gradient-angle-that-actually-animates/): `@property`. Custom properties are strings until you register them. Animate an unregistered --angle and it jumps from start to end with nothing in between — no error, no warning, just a hard cut at 50%. - [A card that reads its own container, not the window](https://web-craft.net/elements/card-that-reads-its-own-container-not-the-window/): `@container`. An element cannot query itself. The container-type goes on a wrapper and the query targets its children, so every queried component needs one box more than you expected. - [Type that scales with its container](https://web-craft.net/elements/type-that-scales-with-its-container/): `cqi`. A cqi unit resolves against the nearest ancestor that declared a container, not the nearest ancestor you had in mind. Nest two containers and the inner one silently wins. - [Boxes that keep their proportions](https://web-craft.net/elements/boxes-that-keep-their-proportions/): `aspect-ratio`. An explicit height beats aspect-ratio every time. The one that catches people is implicit: a flex or grid parent stretches its children by default, and that stretch is a height. - [A heading that breaks where a typesetter would](https://web-craft.net/elements/heading-that-breaks-where-a-typesetter-would/): `text-wrap: balance`. Browsers stop balancing past roughly six lines and quietly do nothing beyond that. It is a headline tool: put it on body copy and you pay for the layout pass and get no result. - [Paragraphs with no orphan last line](https://web-craft.net/elements/paragraphs-with-no-orphan-last-line/): `text-wrap: pretty`. This is the one for body copy, and balance is not. Swapping them is the common mistake: pretty only fixes the last lines, balance evens out every line but gives up on long text. - [Hyphenation in a narrow column](https://web-craft.net/elements/hyphenation-in-a-narrow-column/): `hyphens: auto`. It does nothing without a lang attribute on the element or an ancestor. The browser needs to know which dictionary to hyphenate with, and silently declines rather than guessing. - [An error state that waits its turn](https://web-craft.net/elements/error-state-that-waits-its-turn/): `:user-invalid`. :invalid matches from the moment the page loads, so an empty required field is red before anyone has typed a character. :user-invalid waits until the field has been interacted with. - [Native controls in your palette](https://web-craft.net/elements/native-controls-in-your-palette/): `accent-color`. One property, one colour, no second opinion — the tick, the thumb and the fill are all derived from it. Need the check mark a different colour from the box and you are back to rebuilding the control. - [A label that floats out of the way](https://web-craft.net/elements/label-that-floats-out-of-the-way/): `:placeholder-shown`. The trick needs a real placeholder attribute — even a single space — because the selector matches the placeholder being shown, not the value being empty. Drop the attribute and the label never moves. - [A carousel the browser drives](https://web-craft.net/elements/carousel-the-browser-drives/): `scroll-snap-type`. scroll-snap-type goes on the scroller and scroll-snap-align on every child. Set only the first and nothing snaps, with no indication that half the recipe is missing. - [Scrolling that stops at the edge](https://web-craft.net/elements/scrolling-that-stops-at-the-edge/): `overscroll-behavior`. This is the real fix for the page scrolling behind an open modal. Locking body with overflow: hidden throws away the reading position and jumps the layout by the scrollbar width. - [Anchor links that clear a sticky header](https://web-craft.net/elements/anchor-links-that-clear-a-sticky-header/): `scroll-padding-top`. Jump to an anchor under a sticky header and the heading lands behind it. The offset belongs to the scroll container as scroll-padding-top, or to the target as scroll-margin-top, never to both. - [A wrapper that knows its input is wrong](https://web-craft.net/elements/wrapper-that-knows-its-input-is-wrong/): `:has()`. Specificity is taken from the heaviest selector inside the brackets, not from :has() itself. Put an id in there and the whole rule inherits an id-level weight you did not intend to spend. - [Hovering one row quiets the others](https://web-craft.net/elements/hovering-one-row-quiets-the-others/): `:has() + :not()`. This re-evaluates on every pointer move across the list. On a few dozen rows it is free; on a table of several hundred it is a visible frame cost, and the fix is to move the rule to the row rather than the list. - [A layout that changes when the list gets long](https://web-craft.net/elements/layout-that-changes-when-the-list-gets-long/): `:has(:nth-child())`. A quantity query counts children, so it re-runs whenever the list changes. Keep the threshold to one breakpoint: chaining several counts makes the layout jump twice while items are still loading in. - [A modal that traps focus for you](https://web-craft.net/elements/modal-that-traps-focus-for-you/): `showModal()`. show() and showModal() are different elements in practice. Only the modal one gets ::backdrop, makes the rest of the page inert, traps focus and closes on Escape. Call the wrong one and you have a floating box with none of it. - [A popover with no JavaScript at all](https://web-craft.net/elements/popover-with-no-javascript-at-all/): `popovertarget`. It lives in the top layer, so z-index on it or on anything around it is meaningless. Stacking is decided by the order things were promoted, and that trips up anyone debugging it the usual way. - [A section the page cannot reach](https://web-craft.net/elements/section-the-page-cannot-reach/): `inert`. inert removes the subtree from focus order and from the accessibility tree, which pointer-events: none and disabled do not. Do not add it around an open modal: showModal() already inerts the rest of the page, and doing both can strand focus. - [Animating something in from display: none](https://web-craft.net/elements/animating-something-in-from-display-none/): `@starting-style`. There is nothing to transition from when an element appears: it had no previous style. @starting-style supplies that first frame. Without it the element simply snaps into place and the transition never runs. - [The exit animation, which is the hard half](https://web-craft.net/elements/exit-animation-which-is-the-hard-half/): `allow-discrete`. display is a discrete property: it flips at the halfway point, so the element vanishes mid-fade. allow-discrete makes the browser hold display: none back until the rest of the transition has finished. - [Reordering a list without it jumping](https://web-craft.net/elements/reordering-a-list-without-it-jumping/): `startViewTransition()`. Every element you want animated needs a view-transition-name, and each one has to be unique at the moment of the transition. Two matching names and the transition is skipped outright, with an error only in the console. ## Tools - [Colour palette](https://web-craft.net/palette/): pick one colour and get its analogous, complementary, split-complementary and triad hues plus tint, shade and neutral ramps, all derived in CSS with `oklch(from var(--base) ...)` relative colour syntax. Every swatch is measured after gamut mapping and labelled with whether black or white text clears WCAG AA on it, which a colour wheel does not tell you. ## Tags - [a11y](https://web-craft.net/tags/a11y/) - [control](https://web-craft.net/tags/control/) - [dialog](https://web-craft.net/tags/dialog/) - [disclosure](https://web-craft.net/tags/disclosure/) - [form](https://web-craft.net/tags/form/) - [hover](https://web-craft.net/tags/hover/) - [layout](https://web-craft.net/tags/layout/) - [loading](https://web-craft.net/tags/loading/) - [media](https://web-craft.net/tags/media/) - [menu](https://web-craft.net/tags/menu/) - [motion](https://web-craft.net/tags/motion/) - [scroll](https://web-craft.net/tags/scroll/) - [state](https://web-craft.net/tags/state/) - [surface](https://web-craft.net/tags/surface/) - [table](https://web-craft.net/tags/table/) - [text](https://web-craft.net/tags/text/) - [type](https://web-craft.net/tags/type/) ## Key facts - Focus: CSS and HTML interface elements, their source, and where each one breaks. - 81 elements published. Founded 2026-08-17. - Every demo is live markup running in the page, not a screenshot. - No advertising, affiliate links, or sponsored content. - Catalogue: https://web-craft.net/elements/ - Feed: https://web-craft.net/feed.xml - Contact: hello@web-craft.net