Grain

Grain takes the flatness off a large area of colour — a hero, a coloured band, a card that would otherwise read as a plain rectangle. It wraps content the way container() does, but sets no width of its own: the texture is drawn on ::after, above the children and ignoring the pointer.

It is an extra, so it comes with a stylesheet of its own. Import both from sitelo/ui-extras and put grainStyles() in the head beside styles():

import { styles } from 'sitelo/ui'
import { grain, grainStyles } from 'sitelo/ui-extras'

head(styles(), grainStyles())

The tile is a static SVG of fractal noise, painted once. A filter over the live pixels would look much the same and cost a re-raster every time anything underneath it moved.

There are two layers of control. opacity is how hard the texture is pushed once drawn; left alone the theme sets it, and that is the value the two themes are balanced on. type, frequency, octaves, seed and color are the turbulence itself; touching any of them builds a texture for that one element instead of using the shared one in the stylesheet.

Basic grain

Textured.

grain({ style: 'background: var(--su-surface-2); padding: 2rem; border-radius: 0.75rem' },
  text({ variant: 'lead', align: 'center' }, 'Textured.'),
)

Noise type

fractal sums the noise straight and gives the even speckle of film. turbulence takes its absolute value, which leaves dark veins and clumps — closer to smoke or marble than to grain.

fractal

turbulence

grid({ min: '9rem' },
  ...['fractal', 'turbulence'].map((type) =>
    grain({ type, style: 'background: var(--su-surface-2); padding: 1.5rem 1rem; border-radius: 0.5rem' },
      text({ variant: 'small', align: 'center' }, type),
    ),
  ),
)

Frequency

Cycles per pixel: higher is finer. The noise is drawn at the box’s own size, one unit to the pixel, so this holds whatever the box measures — a small card and a full-width band get the same grain, and nothing repeats.

0.2

0.57

1.2

grid({ min: '9rem' },
  ...[0.2, 0.57, 1.2].map((frequency) =>
    grain({ frequency, style: 'background: var(--su-surface-2); padding: 1.5rem 1rem; border-radius: 0.5rem' },
      text({ variant: 'small', align: 'center' }, String(frequency)),
    ),
  ),
)

Octaves

How many layers of noise are summed, each finer and fainter than the last. One is plain and even; more adds detail, and each one costs the browser another pass when the tile is first drawn.

1

3

6

grid({ min: '9rem' },
  ...[1, 3, 6].map((octaves) =>
    grain({ octaves, style: 'background: var(--su-surface-2); padding: 1.5rem 1rem; border-radius: 0.5rem' },
      text({ variant: 'small', align: 'center' }, String(octaves)),
    ),
  ),
)

Seed

Which noise gets drawn. Any number will do, the same one always gives the same pattern, and nothing else about the texture changes — useful when two grained panels sit side by side and the repeat gives itself away.

0

7

42

grid({ min: '9rem' },
  ...[0, 7, 42].map((seed) =>
    grain({ seed, style: 'background: var(--su-surface-2); padding: 1.5rem 1rem; border-radius: 0.5rem' },
      text({ variant: 'small', align: 'center' }, String(seed)),
    ),
  ),
)

Colour

The noise is grey by default. color tints it — the value is multiplied into the texture inside the filter, so it has to be one that can be resolved when the page is built: #rgb, #rrggbb or rgb(). A named colour, currentColor or a var() cannot be, and leaves the noise grey rather than failing the build. Alpha is how much of the tint: #ff880080 is half of #ff8800, and alpha zero is none.

#0a7a45

#c05621

#2f7fc7

grid({ min: '9rem' },
  ...['#0a7a45', '#c05621', '#2f7fc7'].map((color) =>
    grain({ color, style: 'background: var(--su-surface-2); padding: 1.5rem 1rem; border-radius: 0.5rem' },
      text({ variant: 'small', align: 'center' }, color),
    ),
  ),
)

Around a container

Grain has no width limit of its own, which is what makes this work: the wrapper runs full bleed and the container() inside keeps the text centred and readable.

A textured band

Full width outside, a readable column inside.

grain({ as: 'section', style: 'background: var(--su-primary-soft); padding-block: 2.5rem; border-radius: 0.75rem' },
  container({ size: 'sm' },
    stack({ gap: 'sm', align: 'center' },
      heading({ level: 2, size: 'h4' }, 'A textured band'),
      text({ tone: 'muted', align: 'center' }, 'Full width outside, a readable column inside.'),
    ),
  ),
)

Over a card

The texture inherits the box’s border-radius, so wrapping something rounded does not square its corners off.

Grained

Plain

grid({ min: '12rem' },
  grain({ style: 'border-radius: var(--su-radius-lg)' },
    card({ variant: 'elevated' },
      cardBody(text({ variant: 'small' }, 'Grained')),
    ),
  ),
  card({ variant: 'elevated' },
    cardBody(text({ variant: 'small' }, 'Plain')),
  ),
)

Blending

By default the texture is laid over the content at its own opacity. blend takes any mix-blend-mode — overlay and soft-light push the grain into the colour underneath rather than greying it out.

normal

overlay

soft-light

grid({ min: '9rem' },
  ...['normal', 'overlay', 'soft-light'].map((blend) =>
    grain({ blend, style: 'background: var(--su-primary-soft); padding: 1.5rem 1rem; border-radius: 0.5rem' },
      text({ variant: 'small', align: 'center' }, blend),
    ),
  ),
)

Sandbox

0.57
3
0
0.16
Transparent or white leaves it grey; alpha is how much.

Play with the controls.

grain(…)

From script

setGrain() from sitelo/ui-extras/client redraws a grain by element or by id: only what is passed changes, and it returns what the grain is showing now — which getGrain() reads on its own, the theme’s opacity included. The sandbox above is nothing else.

import { setGrain } from 'sitelo/ui-extras/client'

setGrain('hero', { type: 'turbulence', seed: 7 })

Props

PropTypeDefaultDescription
opacitynumber—Opacity of the texture. Left alone, the theme sets it.
blendstring'normal'A mix-blend-mode for the texture.
type'fractal' | 'turbulence''fractal'Which turbulence to draw.
frequencynumber0.57Cycles per pixel — higher is finer.
octavesnumber3Layers of noise summed together, 1–8.
seednumber0Which noise to draw.
colorstring—Tints the noise; alpha is how much. #rgb, #rrggbb, #rrggbbaa, rgb() or rgba().
asstring'div'Element to render, e.g. section.