Gradients & Effects

NEW
A gradient written as a value, so its stops are palette colours — four shadows that stack on one box-shadow, and nine filter functions that stack the same way.

A gradient is a value, not a string

Written as CSS a gradient is text, and text cannot be themed, cannot take a token and cannot share a class. Here it is a record: the key names the kind and carries its geometry, colors are the stops in order. Because a stop is an ordinary colour value, a gradient follows the palette into dark mode, takes the opacity modifier, and two elements asking for the same one still resolve to a single rule.

The three kinds
JSX
<Box bgGradient={{ linear: 'r', colors: ['blue-500', 'pink-500'] }} />
<Box bgGradient={{ linear: 135, colors: ['blue-500', 'pink-500'] }} />
<Box bgGradient={{ radial: 'circle', at: 'top left', colors: ['sky-400', 'indigo-900'] }} />
<Box bgGradient={{ conic: 45, colors: ['red-500', 'yellow-500', 'red-500'] }} />

Direction, shape, angle

linear takes one of eight directions — t, tr, r, br, b, bl, l, tl — or a number, which is an angle in degrees with 0 pointing up. radial takes a shape, conic an angle to start from, and either takes at to say where it is centred. A linear gradient runs in a direction rather than out of a point, so at is not one of its keys.

One of each
linear: 'r'
linear: 135
radial at top left
conic: 45
JSX
<Flex gap={4} flexWrap="wrap">
  <Box width={40} height={28} borderRadius={2} bgGradient={{ linear: 'r', colors: ['blue-500', 'pink-500'] }} />
  <Box width={40} height={28} borderRadius={2} bgGradient={{ linear: 135, colors: ['emerald-400', 'sky-600'] }} />
  <Box width={40} height={28} borderRadius={2} bgGradient={{ radial: 'circle', at: 'top left', colors: ['amber-300', 'rose-600'] }} />
  <Box width={40} height={28} borderRadius={2} bgGradient={{ conic: 45, colors: ['violet-500', 'cyan-400', 'violet-500'] }} />
</Flex>

A stop is a colour, and every colour value works

Anything a colour prop takes is a stop: a palette token, a token with an opacity modifier, a system colour, or a var(--chart-1) somebody else declared. A [colour, position] pair says how far along the gradient the stop sits. Two stops is the minimum, because one stop is a colour rather than a gradient.

Positions and the opacity modifier
positioned stops
black/50 to transparent
JSX
<Flex gap={4} flexWrap="wrap">
  <Box width={40} height={28} borderRadius={2} bgGradient={{ linear: 'r', colors: [['sky-500', '20%'], ['indigo-700', '80%']] }} />
  <Box width={40} height={28} borderRadius={2} bgColor="amber-400" bgGradient={{ linear: 'b', colors: ['black/50', 'transparent'] }} />
</Flex>

The space the colours travel through

Two stops are joined by a path, and sRGB — the browser default — runs that path through a desaturated middle. Blue to yellow goes grey on the way. interpolate names a better space: oklch keeps the chroma up the whole way, and oklch-longer takes the long way round the hue circle, which is what turns two stops into a spectrum. This is the half of OKLCH the palette work deliberately left here.

The same two stops, three ways
default — sRGB, grey in the middle
interpolate: 'oklch'
interpolate: 'oklch-longer' — the long way round
JSX
<Flex d="column" gap={3}>
  <Box height={12} borderRadius={2} bgGradient={{ linear: 'r', colors: ['blue-600', 'yellow-400'] }} />
  <Box height={12} borderRadius={2} bgGradient={{ linear: 'r', colors: ['blue-600', 'yellow-400'], interpolate: 'oklch' }} />
  <Box height={12} borderRadius={2} bgGradient={{ linear: 'r', colors: ['blue-600', 'yellow-400'], interpolate: 'oklch-longer' }} />
</Flex>

Judged whole, so a typo shows nothing rather than something else

A gradient is one value: a bad stop makes the rest of it meaningless, so the whole record is rejected together and emits no rule and no class name. Unknown keys are rejected too, which vars does not do — these names belong to the grammar, and interpolat misspelt would otherwise paint a silent sRGB gradient with nothing to see.

Each of these emits nothing at all
JSX
<Box bgGradient={{ linear: 'r', colors: ['bleu-500', 'pink-500'] }} />   {/* no such token */}
<Box bgGradient={{ linear: 'r', colors: ['blue-500'] }} />              {/* one stop is a colour */}
<Box bgGradient={{ linear: 'r', radial: true, colors: [...] }} />        {/* two kinds at once */}
<Box bgGradient={{ linear: 'r', colors: [...], interpolat: 'oklch' }} /> {/* misspelt key */}

Four shadows, one property

CSS gives an element one box-shadow, which normally means the last rule wins and an elevation and a ring cannot coexist. Here each is a layer with a custom property of its own, and all four write the same composed declaration — so they compose the way translateX and translateY do. shadow is Tailwind elevation, from xxs to xxl; the older small, medium and large presets still work and carry their own colour.

The elevation scale
xxs
xs
sm
md
lg
xl
xxl
JSX
<Flex gap={5} flexWrap="wrap">
  <Box width={24} height={16} borderRadius={2} bgColor="white" shadow="xs" />
  <Box width={24} height={16} borderRadius={2} bgColor="white" shadow="sm" />
  <Box width={24} height={16} borderRadius={2} bgColor="white" shadow="md" />
  <Box width={24} height={16} borderRadius={2} bgColor="white" shadow="lg" />
  <Box width={24} height={16} borderRadius={2} bgColor="white" shadow="xl" />
</Flex>

A ring is not an outline

ring and insetRing are a width in pixels rather than a step on a scale. A ring follows borderRadius, costs no layout and joins the shadow stack — all three things an outline does not do — so a focus ring, an inner hairline and an elevation can all be on the same element at once.

Stacked, not replaced
ring={2}
insetRing={1}
insetShadow
both at once
JSX
<Flex gap={5} flexWrap="wrap">
  <Box width={28} height={18} borderRadius={3} bgColor="white" ring={2} ringColor="indigo-500" />
  <Box width={28} height={18} borderRadius={3} bgColor="white" insetRing={1} insetRingColor="slate-300" />
  <Box width={28} height={18} borderRadius={3} bgColor="white" shadow="lg" ring={3} ringColor="indigo-500/40" />
</Flex>

Every layer has a colour of its own

shadowColor, insetShadowColor, ringColor and insetRingColor take every colour value, opacity modifier included. Each shows nothing on its own — a colour with no layer painting it is as inert as borderColor with no border width. none on a shadow, or 0 on a ring, clears just that one layer and leaves the others painting.

A coloured elevation
indigo-500/40
emerald-500/40
rose-500/40
JSX
<Flex gap={5} flexWrap="wrap">
  <Box width={28} height={18} borderRadius={3} bgColor="white" shadow="lg" shadowColor="indigo-500/40" />
  <Box width={28} height={18} borderRadius={3} bgColor="white" shadow="lg" shadowColor="emerald-500/40" />
</Flex>

And the same again for text

textShadow runs xxs to lg with textShadowColor beside it. It is one property with one contributor, so it needs no composing — but it is in the same transition group, and transition="shadow" covers box-shadow and text-shadow both.

textShadow
Raised type
JSX
<P fontSize={28} fontWeight={700} textShadow="md" textShadowColor="indigo-500/40">
  Raised type
</P>

Why the layers are registered

The four custom properties are declared with @property in the base stylesheet, and the reason is inheritance: a custom property inherits by default, so a child asking only for a ring would read its parent value through the fallback and wear an elevation nobody gave it. Registering them inherits: false stops that. The universal syntax with no initial value is deliberate too — anything else makes the property always valid, and the fallback carrying each step of its own alpha would never be reached.

Nine functions, one filter

filter is a list, and its functions apply in sequence — which is the same problem the shadows have, solved the same way. blur, brightness, contrast, grayscale, hueRotate, invert, saturate, sepia and dropShadow each set a layer of their own and all nine write the same composed declaration, so a blur and a desaturation coexist instead of overwriting each other. The order is the grammar's rather than yours: fixing it is what lets two elements naming the same two functions share one class.

The same tile, filtered
no filter
blur="xs"
grayscale={100}
sepia={80}
hueRotate={140}
invert={100}
blur + saturate
JSX
<Box bgGradient={{ linear: 'br', colors: ['amber-400', 'rose-600'] }} blur="xs" />
<Box bgGradient={{ linear: 'br', colors: ['amber-400', 'rose-600'] }} grayscale={100} />
<Box bgGradient={{ linear: 'br', colors: ['amber-400', 'rose-600'] }} hueRotate={140} />
<Box bgGradient={{ linear: 'br', colors: ['amber-400', 'rose-600'] }} blur="xs" saturate={40} />

A number is the function's own unit

Six of them are a percentage — brightness={110} is ten percent brighter, grayscale={100} removes colour entirely — hueRotate is degrees, and blur is a radius in pixels with Tailwind's scale beside it, xs (4px) through xxxl (64px). none clears one function and leaves the other eight painting, the way none clears one shadow layer.

A drop shadow follows the shape

shadow is cast by the box; dropShadow is cast by what is actually drawn — the outline of an SVG path, the opaque part of a transparent PNG. It is a filter function rather than a fifth shadow layer, which is exactly why the two can be told apart: put both on a triangle and one of them is a rectangle. xs through xxl, with dropShadowColor beside it.

box-shadow and drop-shadow on the same triangle
shadow — the box
dropShadow — the shape
dropShadowColor
JSX
<Svg viewBox="0 0 64 56" width="64px" fill="indigo-500" shadow="xl">
  <Polygon points="32,4 60,52 4,52" />
</Svg>
<Svg viewBox="0 0 64 56" width="64px" fill="indigo-500" dropShadow="xl">
  <Polygon points="32,4 60,52 4,52" />
</Svg>

And the same nine behind the element

backdropBlur and its eight siblings filter what is behind the element rather than the element itself, which is the whole of glassmorphism: something translucent in front, the page blurred and pushed behind it. There is a backdropOpacity that filter has no use for and no backdropDropShadow, because neither one means anything on the other side.

Glass over a gradient
backdropBlur
+ saturate
+ grayscale
JSX
<Box backdropBlur="sm" backdropSaturate={180} bgColor="white/20" insetRing={1} insetRingColor="white/40">
  Frosted
</Box>

A mask is a gradient again

maskImage takes the same record bgGradient does, and reads its alpha channel: where the gradient is transparent the element is not painted. A fade to transparent is the whole edge-fade recipe — the one a scrolling list wants at its bottom edge — and because the value is the gradient grammar it takes tokens, positions and interpolation like any other. A url(#id) masks by a shape the document defines. One mask, not a stack.

Fading an edge
no mask
fade to bottom
radial vignette
JSX
<Box
  bgGradient={{ linear: 'r', colors: ['sky-500', 'indigo-600'] }}
  maskImage={{ linear: 'b', colors: ['black', ['black', '55%'], 'transparent'] }}
/>

And a gradient can be the letters

bgClip="text" clips the background to the glyphs, which is how a gradient becomes type. It needs color="transparent" beside it — the text paints over its own background otherwise — and that pairing is deliberately not automatic: bgClip is the CSS property, not a recipe.

Gradient type
Painted by the background
JSX
<H2 fontSize={40} fontWeight={800} color="transparent" bgClip="text" bgGradient={{ linear: 'r', colors: ['violet-500', 'cyan-400'] }}>
  Painted by the background
</H2>