Plugins
FieldsColor

Linked mode

Store preset references instead of resolved values, so palette changes propagate everywhere.

By default, picking a preset stores its literal value; documents keep the color they were saved with. Linked mode inverts that: picking a preset stores a reference, and the color resolves at read time. Change the preset's value once and every document using it updates on next read.

fields: [
  ...colorField({
    name: 'accent',
    linked: { fallback: '#64748b' },
    presets: async ({ req }) => resolveGovernedPalette(req),
  }),
]

Linked mode returns a tuple, the stored field plus its resolved virtual sibling, so spread it into fields.

What gets stored

  • Picking a preset stores preset:<key> (for example preset:brand).
  • Moving the alpha slider with a preset active stores preset:<key>/<alpha> (for example preset:brand/40), keeping the reference; see opacity on references.
  • Picking a custom color stores the plain CSS string, exactly like default mode.

Both live in the same text column, so where: { accent: { equals: 'preset:brand' } } finds every document linked to a preset, which makes "where is this brand color used" a one-query answer.

The resolved sibling

The factory adds a hidden virtual sibling <name>Resolved populated on read with the resolved CSS value:

const doc = await payload.findByID({ collection: 'pages', id })
doc.accent          // 'preset:brand'
doc.accentResolved  // '#3b82f6'

Resolution deliberately happens on the sibling, not in place. Resolving in place would feed the resolved value back through the admin form and silently overwrite the reference on the next save; the sibling keeps the stored field round-trip safe. Frontends read <name>Resolved; the admin and queries work with the stored reference.

Because the async preset resolver is memoized per request and shared across every document, it derives from req and returns the full governed palette (keyed by preset key); each document's stored reference selects its entry. Resolving a list view costs one palette lookup.

Scheme-aware resolution

When presets carry light/dark pairs, linked.resolve chooses the sibling's shape:

fields: [
  ...colorField({
    name: 'accent',
    linked: { resolve: 'schemes', fallback: { light: '#94a3b8', dark: '#1e293b' } },
    presets: async ({ req }) => resolveGovernedPalette(req),
  }),
]

resolve: 'value' (the default) keeps <name>Resolved a text field. resolve: 'schemes' makes it a JSON field carrying { light, dark }. The sibling's name is the same in both shapes, so the shape is the only thing your frontend switches on.

Stored valueresolve: 'value'resolve: 'schemes'
emptynullnull
a custom colorthe string{ light: it, dark: it }
a reference to a flat presetthe preset's value{ light: value, dark: value }
a reference to a scheme presetthe light member{ light, dark }
a reference that no longer resolvesfallbackfallback, widened to a pair
a reference that no longer resolves, no fallbacknullnull

fallback accepts a pair as well as a string. Under resolve: 'value' a pair fallback flattens to its light member, exactly like a pair preset does.

Flattening to light under resolve: 'value' is deliberate: a field configured before scheme presets existed keeps working when its resolver starts returning them, rather than breaking.

To render a pair, lightDark() from the color utilities produces the CSS one-liner:

<div style={{ colorScheme: 'light dark', background: lightDark(doc.accentResolved) }} />

Opacity on references

A reference can carry an opacity without giving up the link. With a preset active, the picker's alpha slider rewrites the stored reference instead of committing a concrete color:

preset:<key>            reference at full opacity (canonical for alpha 100)
preset:<key>/<alpha>    reference at <alpha> percent

<alpha> is an integer 0 to 100, no percent sign, no decimals: preset:brand/40. The suffix is read off the last slash and only counts as an alpha when it matches that grammar, so keys containing slashes (as produced by keyPrefix) keep working: preset:acme/surface is a key, preset:acme/surface/40 is that key at 40%. Alpha 100 always stores as the bare reference; a hand-typed /100 is accepted and normalized on commit.

In the picker:

  • The alpha slider keeps the reference and rewrites the suffix; returning it to 100 restores the bare reference.
  • Hue and saturation interactions still convert to a concrete color; only alpha keeps the link.
  • The preset chip shows the opacity next to the label when it is below 100, and the swatch renders the resolved color at the stored alpha.
  • Typing preset:brand/40 into the text input is valid and commits as such.
  • alpha: false on the field disables reference alpha too: the slider is hidden and a hand-typed suffix is stripped on commit.

Everywhere a reference resolves, the alpha applies to the result: both resolve shapes carry it in the field's configured format (8-digit hex under format: 'hex'), on both members of a scheme pair, multiplied into any alpha the preset color itself carries (the same math as CSS color-mix toward transparent).

Validation accepts the grammar above and rejects near-misses: an empty key (preset:/40), a valid suffix on a key that does not exist (preset:ghost/40), and numeric suffixes outside the grammar (preset:brand/101, preset:brand/4.5) all fail. Bare references without a suffix stay lenient, preserving the missing-preset fallback flow.

To consume references in presentation code, parsePresetReference from the color utilities splits a stored value without hand-rolled regexes:

import { parsePresetReference } from '@10x-media/fields/color/utils'

parsePresetReference('preset:brand/40')  // { key: 'brand', alpha: 40 }
parsePresetReference('preset:brand')     // { key: 'brand', alpha: 100 }
parsePresetReference('#3b82f6')          // null

Scheme presets in non-linked fields

A non-linked field stores a preset's literal value, and a text column holds one color. Picking a scheme preset there stores its light member. The picker still shows the split swatch, so it stays visible that the field is holding one half of a mode-responsive color. Use linked mode when you need both.

Missing presets

When a stored reference points at a preset that no longer exists (a tenant deleted a brand color), the sibling resolves to linked.fallback (default null), and the picker shows a missing-preset state prompting for a replacement. Nothing throws, and the stale reference stays queryable until an editor replaces it.

Limitations

The resolved sibling is derived at read time from the stored reference on the same record, which has two edges worth knowing:

  • Reading a localized linked field with locale=all returns null for ${name}Resolved. The sibling resolves one reference, not the per-locale map that locale=all returns, so per-locale resolved values are not represented. Read a single locale when you need the resolved value.
  • Selecting ${name}Resolved without also selecting the main field returns null, because resolution reads the main field's stored reference from the same record. Co-select the main field (for example select: { accent: true, accentResolved: true }).

When to use which mode

Use linked mode when colors are governed (tenant brands, design tokens) and should update globally. Use default mode when a document's color is that document's own data. The two can coexist: linked fields still allow custom one-off values, which store as plain strings.

On this page