Gradients
A gradient is a color that changes across a box.
Every place a style holds a color can hold one instead:
margin_color, padding_color and content_color,
and the foreground and background of border_style, text_style, and a Text's chunks.
This page covers making gradients, where they map, and what drawing them in cells changes.
It doesn't cover transparency or layering one gradient over another, since colors have no alpha.
Counterweight takes gradients from CSS:
a gradient is CSS's linear-gradient, radial-gradient or conic-gradient,
built by a function of the same name with the same parts.
CSS paints a gradient as an image, so only backgrounds and borders take one;
here, as in SVG's fill and stroke, a gradient goes anywhere a color does,
text included, without CSS's background-clip: text.
Linear gradients
linear_gradient takes its colors in order,
and points down unless it's given a side or corner to point to, or an angle.
An angle is in degrees clockwise from up, so 90 points right, as in CSS.
A corner points the way that puts the gradient's halfway line through the other two corners,
so the corner itself takes the last color whatever the box's shape (CSS's "magic corners").
@component
def linear() -> Div:
return Div(
style=row | gap(2),
children=(
labeled("to right", Div(style=SWATCH | content_gradient(linear_gradient(rose_500, sky_500, to="right")))),
labeled("angle=45", Div(style=SWATCH | content_gradient(linear_gradient(rose_500, sky_500, angle=45)))),
labeled(
"to top right",
Div(style=SWATCH | content_gradient(linear_gradient(rose_500, sky_500, to="top right"))),
),
),
)
Radial and conic gradients
radial_gradient spreads its colors outward from a center, at,
to an ending shape: an ellipse, or a circle,
sized by the side or corner of the box it reaches ("farthest-corner" unless it's given another),
or by its radius.
conic_gradient turns its colors around a center,
clockwise from its start angle.
A center is a fraction of the way across the box on each axis, so (0.5, 0.5), the default, is its middle,
or a number of cells from its top left cell, as a waxy.Length.
@component
def shapes() -> Div:
return Div(
style=row | gap(2),
children=(
labeled("ellipse", Div(style=SWATCH | content_gradient(radial_gradient(amber_300, violet_900)))),
labeled(
"circle at a corner",
Div(
style=SWATCH | content_gradient(radial_gradient(amber_300, violet_900, shape="circle", at=(0, 0))),
),
),
labeled(
"conic",
Div(
style=SWATCH | content_gradient(conic_gradient(rose_500, amber_300, teal_500, violet_600, rose_500))
),
),
),
)
Stops
Each color is a stop, which goes wherever the gradient's other stops leave room for it,
or at a position given with it as (color, position), a fraction of the way along the gradient,
or from one position to another as (color, start, end).
Two stops at one position make a hard edge between them.
A Hint between two stops moves the point where their colors mix half and half,
and a repeating gradient repeats its stops for as long as the box goes on.
Stops are fixed up as CSS fixes them up: a position smaller than one before it is moved up to it,
and stops without positions are spaced evenly between the ones around them.
@component
def stops() -> Div:
return Div(
style=row | gap(2),
children=(
labeled(
"a hint at 0.2",
Div(style=SWATCH | content_gradient(linear_gradient(gray_950, Hint(0.2), gray_100, to="right"))),
),
labeled(
"hard stops",
Div(
style=SWATCH
| content_gradient(
linear_gradient(
(rose_500, 0, 1 / 3), (amber_300, 1 / 3, 2 / 3), (teal_500, 2 / 3, 1), to="right"
)
),
),
),
labeled(
"repeating",
Div(
style=SWATCH
| content_gradient(
linear_gradient((sky_500, 0), (gray_950, 0.2), (sky_500, 0.4), angle=120, repeating=True)
),
),
),
),
)
A stop is a Color, not an IndexedColor or TERMINAL_DEFAULT,
since the terminal decides what those look like, so there's no color to mix them from.
Mixing colors
A gradient mixes its colors in Oklab unless it's given another color space as its interpolation,
as CSS does, which keeps the colors between two stops about as bright as the stops, rather than going muddy.
"srgb" mixes them as CSS does colors given in hex, and a polar space such as "oklch"
goes around the hue circle the shorter way, or the way its hue says.
@component
def interpolation() -> Div:
return Div(
style=col | gap(0),
children=tuple(
Div(
style=row,
children=(
Text(content=space, style=width(8)),
Div(
style=size(40, 1)
| content_gradient(linear_gradient(blue_600, yellow_300, to="right", interpolation=space)),
),
),
)
for space in ("srgb", "oklab", "oklch")
),
)
Where a gradient goes
The utilities put a gradient in each place a color goes. Each place maps its gradient over the box it paints:
| Utility | Field | Maps over |
|---|---|---|
margin_gradient |
margin_color |
the margin box |
content_gradient |
content_color |
the border box, under the border and padding |
padding_gradient |
padding_color |
the padding box |
border_gradient, border_bg_gradient |
border_style |
the border box |
text_gradient, text_bg_gradient |
text_style, and a chunk's style |
the content box |
SUNRISE = linear_gradient(amber_300, rose_500, to="right")
@component
def slots() -> Div:
return Div(
style=row | gap(2),
children=(
Div(
style=size(20, 5) | border | border_heavy | border_gradient(SUNRISE),
children=(Text(content="gradient border", style=pad_x(1)),),
),
Div(
style=size(24, 5) | pad_x(1),
children=(
Text(
content=(
Chunk(content="Gradient text, "),
Chunk(content="with a highlight", style=text_bold),
Chunk(content=" that continues it"),
),
style=text_wrap_pretty | text_gradient(SUNRISE),
),
),
),
Div(
style=size(20, 5)
| pad(1)
| padding_gradient(linear_gradient(teal_500, sky_700))
| content_gradient(radial_gradient(gray_100, gray_400)),
),
),
)
A chunk's gradient maps over the element's content box too, so a highlighted word in gradient text continues the text's gradient rather than starting it over. Text that overflows its box keeps going along its gradient, as does any part of a place outside the box it maps over.
A gradient's box names the box it maps over instead,
so two places with the same gradient can share a box and line up,
as CSS's background-origin does:
DUSK = linear_gradient(violet_500, sky_400, to="right")
DUSK_OVER_THE_MARGIN = linear_gradient(violet_500, sky_400, to="right", box="margin")
@component
def boxes() -> Div:
return Div(
style=col,
children=(
Text(content="each over its own box"),
Div(style=size(12, 2) | margin_x(4) | margin_gradient(DUSK) | content_gradient(DUSK)),
Text(content="both over the margin box"),
Div(
style=size(12, 2)
| margin_x(4)
| margin_gradient(DUSK_OVER_THE_MARGIN)
| content_gradient(DUSK_OVER_THE_MARGIN)
),
),
)
What cells change
A cell is about twice as tall as it is wide, so a gradient computes its angles and circles as if each row
were two columns tall, which makes 45 degrees look like 45 degrees, and a circle look round.
The ratio is app's cell_aspect_ratio parameter, which a font with other proportions can change.
A circle or ellipse given radii in cells keeps them: a circle of radius 10 reaches 10 columns across,
and 5 rows down.
A browser colors each pixel by its center, and so does a terminal each cell, but a gradient spans from the center of its box's first cell to the center of its last, so the cells at the ends take the end colors exactly: a box 3 cells tall shows its first color, the color halfway between, and its last, where a browser would show neither end.
On a canvas
A Canvas is drawn by its painter, which has no places for a gradient,
so HalfBlockGradient is a painter that fills it with one,
two pixels to a cell, as HalfBlocks draws a picture,
which shows a gradient at twice the vertical resolution of a content_gradient.
Pixels.from_gradient is a picture of a gradient,
for a picture that draws more on top of it.
ARC = conic_gradient(rose_500, amber_300, teal_500, violet_600, rose_500)
@component
def on_a_canvas() -> Div:
return Div(
style=row | gap(2),
children=(
labeled("content_gradient", Div(style=SWATCH | content_gradient(ARC))),
labeled("HalfBlockGradient", Canvas(painter=HalfBlockGradient(ARC), style=SWATCH)),
),
)
Animating a gradient
A gradient is a value, so animating one means building a new one each frame from the time,
which use_clock reads.
Each of these turns its gradient's angle, or slides its stops along, once every three seconds:
LOOP = timedelta(seconds=3)
@component
def turning() -> Div:
start = 360 * use_loop(LOOP)
return Div(style=SWATCH | content_gradient(conic_gradient(rose_500, amber_300, teal_500, rose_500, start=start)))
@component
def orbiting_border() -> Div:
angle = 360 * use_loop(LOOP)
return Div(
style=SWATCH | border | border_heavy | border_gradient(linear_gradient(amber_300, violet_600, angle=angle)),
children=(Text(content="orbiting", style=pad_x(1)),),
)
@component
def shimmer(content: str) -> Text:
# The highlight starts off the left end and finishes off the right one, so the loop starts over unseen.
at = 1.6 * use_loop(LOOP) - 0.3
return Text(
content=content,
style=text_gradient(
linear_gradient((slate_500, at - 0.25), (slate_50, at), (slate_500, at + 0.25), to="right")
),
)
@component
def animated() -> Div:
return Div(
style=row | gap(2),
children=(
labeled("turning", turning()),
labeled("orbiting border", orbiting_border()),
labeled("shimmer", Div(style=SWATCH, children=(shimmer("Thinking…"),))),
),
)
Browsers don't animate one gradient into another,
so a page animates a gradient by sliding an oversized background with background-position,
or by registering an angle or a stop position with @property so that it can transition.
Here a gradient's angle, center, stops and colors are arguments like any other,
so any of them can change from one frame to the next.
A stop's position can be outside the gradient, as the shimmer's are when its highlight is off either end.
Each animated component reads the clock itself, so a frame re-runs only them,
though each frame draws their gradients again (see Performance).
Performance
Paint keeps what it builds for each element's gradients from frame to frame, so a gradient that doesn't change costs about what a solid color does once it's drawn. What costs is drawing one again: a gradient whose stops or geometry change, as in an animation, or that scrolls, or whose box changes size, is drawn again in every frame it changes, at roughly half a millisecond per thousand cells, and all of its cells' colors are sent to the terminal again too.
API
counterweight.gradients.linear_gradient
linear_gradient(
*stops: StopLike,
to: SideOrCorner | None = None,
angle: float | None = None,
repeating: bool = False,
interpolation: Interpolation = "oklab",
hue: HueMethod = "shorter",
box: Box | None = None,
) -> LinearGradient
A LinearGradient through stops,
pointing to a side or corner, or at an angle in degrees, or down if neither is given.
Giving both raises ValueError.
counterweight.gradients.radial_gradient
radial_gradient(
*stops: StopLike,
shape: Shape | None = None,
size: Extent
| Length
| tuple[Coordinate, Coordinate] = "farthest-corner",
at: tuple[Coordinate, Coordinate] = (0.5, 0.5),
repeating: bool = False,
interpolation: Interpolation = "oklab",
hue: HueMethod = "shorter",
box: Box | None = None,
) -> RadialGradient
A RadialGradient through stops, centered at a point.
Without a shape, it's a circle if size is one radius, and an ellipse otherwise, as in CSS.
counterweight.gradients.conic_gradient
conic_gradient(
*stops: StopLike,
start: float = 0,
at: tuple[Coordinate, Coordinate] = (0.5, 0.5),
repeating: bool = False,
interpolation: Interpolation = "oklab",
hue: HueMethod = "shorter",
box: Box | None = None,
) -> ConicGradient
A ConicGradient through stops, around a point at.
counterweight.gradients.Gradient
Gradient = LinearGradient | RadialGradient | ConicGradient
A color that changes across the box it maps over, which any color slot of a style can hold.
counterweight.gradients.LinearGradient
dataclass
LinearGradient(
*,
stops: Stops,
direction: float | SideOrCorner = "bottom",
repeating: bool = False,
interpolation: Interpolation = "oklab",
hue: HueMethod = "shorter",
box: Box | None = None,
)
Colors that change along a line through the middle of the box, as CSS's
linear-gradient does.
direction is an angle in degrees, where 0 points up and angles turn clockwise, so 90 points right,
or the side or corner the gradient points to, where a corner points the way that puts the line the gradient
is halfway along through the other two corners, so the corner takes the last color whatever the box's shape.
The stops' positions are fractions of the gradient line, which is as long as the box is across in its direction.
Building one fixes up its stops, and raises ValueError for fewer than two color stops,
a hint that isn't between two of them, or a hue method in a color space that isn't polar.
resolved
class-attribute
instance-attribute
resolved: ResolvedStops = dataclass_field(
init=False, repr=False, compare=False, hash=False
)
counterweight.gradients.RadialGradient
dataclass
RadialGradient(
*,
stops: Stops,
shape: Shape = "ellipse",
size: Extent
| Length
| tuple[Coordinate, Coordinate] = "farthest-corner",
at: tuple[Coordinate, Coordinate] = (0.5, 0.5),
repeating: bool = False,
interpolation: Interpolation = "oklab",
hue: HueMethod = "shorter",
box: Box | None = None,
)
Colors that change outward from a point, as CSS's
radial-gradient does.
The stops' positions are fractions of the way from the center, at, to the ending shape,
a shape whose size is the side or corner of the box it reaches, or its radius:
one Length for a circle, in columns, and two for an ellipse, in columns and rows,
either of which can also be a fraction of the box's width or height.
Building one fixes up its stops, and raises ValueError for fewer than two color stops,
a hint that isn't between two of them, a hue method in a color space that isn't polar,
a size that doesn't match its shape, or a negative radius.
size
class-attribute
instance-attribute
size: Extent | Length | tuple[Coordinate, Coordinate] = (
"farthest-corner"
)
resolved
class-attribute
instance-attribute
resolved: ResolvedStops = dataclass_field(
init=False, repr=False, compare=False, hash=False
)
counterweight.gradients.ConicGradient
dataclass
ConicGradient(
*,
stops: Stops,
start: float = 0,
at: tuple[Coordinate, Coordinate] = (0.5, 0.5),
repeating: bool = False,
interpolation: Interpolation = "oklab",
hue: HueMethod = "shorter",
box: Box | None = None,
)
Colors that change around a point, as CSS's
conic-gradient does.
The stops' positions are fractions of a turn clockwise from start, an angle in degrees where 0 points up,
around the center, at.
Building one fixes up its stops, and raises ValueError for fewer than two color stops,
a hint that isn't between two of them, or a hue method in a color space that isn't polar.
resolved
class-attribute
instance-attribute
resolved: ResolvedStops = dataclass_field(
init=False, repr=False, compare=False, hash=False
)
counterweight.gradients.Stop
dataclass
counterweight.gradients.Hint
dataclass
Hint(at: float)
A transition hint, between two color stops: where along the gradient line their colors mix half and half, as a fraction of the gradient line.
counterweight.gradients.StopLike
A stop as the gradient functions take it, as CSS spells one: a bare color, a color at a position, a color from one position to another (two stops), or a hint.
counterweight.gradients.Interpolation
Interpolation = Literal[
"srgb",
"srgb-linear",
"oklab",
"oklch",
"lab",
"lch",
"hsl",
"hwb",
"xyz",
"xyz-d50",
"xyz-d65",
]
The color space a gradient's colors are mixed in, as CSS's in <color-space> names it.
"oklch", "lch", "hsl" and "hwb" are polar, so their hue takes a HueMethod.
counterweight.gradients.HueMethod
HueMethod = Literal[
"shorter", "longer", "increasing", "decreasing"
]
Which way around the hue circle a polar color space mixes two hues, as CSS's <hue-interpolation-method>.
counterweight.gradients.Box
Box = Literal['margin', 'border', 'padding', 'content']
Which of an element's boxes a gradient maps over.
counterweight.gradients.Coordinate
Coordinate = float | Length
A point along one axis of a box: a fraction of the way across it, or a number of cells from its first cell.
counterweight.gradients.SideOrCorner
SideOrCorner = Literal[
"top",
"right",
"bottom",
"left",
"top left",
"top right",
"bottom left",
"bottom right",
"left top",
"right top",
"left bottom",
"right bottom",
]
Where a linear gradient points, as CSS's to <side-or-corner>.
counterweight.gradients.Extent
Extent = Literal[
"closest-side",
"closest-corner",
"farthest-side",
"farthest-corner",
]
How large a radial gradient's ending shape is, by the side or corner of the box it reaches.