Skip to content
react-wrapped

Custom palette and chrome colours

There are two sets of colours and they are set in two places. The video has a palette: it belongs to the frame, the viewer can change it, and it is baked into the exported file. The chrome around the video (the player's controls, the settings, the scan panel, the gallery) reads ten CSS custom properties that you set once in your stylesheet. This guide covers both.

In the last 12 months you made

0

contributions.

OctNovDecJanFebMarAprMayJunJulAugSepOct

That's 4.6 a day, on 257 active days out of 365.

palette: "custom", accent: "#ff5c8a"

One accent in, a whole palette out.

A palette from one colour

Set palette to custom and accent to your colour as #rrggbb. mode still says dark or light. Everything else is worked out from the accent, so the result always fits itself.

own-colour.tsx
import { type CSSProperties, useMemo, useState } from "react";

import {
  CUSTOM_PALETTE,
  randomAccent,
  resolvePalette,
  type SceneInstance,
  SceneThumb,
  type WrappedConfig,
} from "@react-wrapped/core";

const COLUMNS: CSSProperties = { display: "flex", flexWrap: "wrap", gap: 24, alignItems: "flex-start" };
const SWATCHES: CSSProperties = { display: "flex", flexWrap: "wrap", gap: 6, marginTop: 14 };
const SWATCH: CSSProperties = {
  width: 34,
  height: 34,
  borderRadius: 9,
  boxShadow: "inset 0 0 0 1px rgb(0 0 0 / 0.12)",
};

export function OwnColour({ scene, config }: { scene: SceneInstance; config: WrappedConfig }) {
  const [accent, setAccent] = useState("#ff5c8a");
  // One colour in, a whole palette out. Dark or light still comes from the config's `mode`.
  const own = useMemo((): WrappedConfig => ({ ...config, palette: CUSTOM_PALETTE, accent }), [config, accent]);
  const palette = resolvePalette(own);
  return (
    <div style={COLUMNS}>
      <SceneThumb scene={scene} config={own} width={280} loop />
      <div>
        <div role="group" aria-label="Accent colour">
          <input
            type="color"
            aria-label="Accent colour"
            value={accent}
            onChange={(event) => setAccent(event.target.value)}
          />
          <button type="button" onClick={() => setAccent(randomAccent())}>
            Surprise me
          </button>
        </div>
        {/* The five levels the accent was stretched into, then the four rarity colours cut from it. */}
        <div style={SWATCHES}>
          {[...palette.levels, ...Object.values(palette.rarity)].map((colour, index) => (
            // oxlint-disable-next-line react/no-array-index-key -- nine fixed swatches, and two may share a colour
            <i key={index} title={colour} style={{ ...SWATCH, background: colour }} />
          ))}
        </div>
        <output>{`palette: "custom", accent: "${accent}"`}</output>
      </div>
    </div>
  );
}
  • Level 0 is a quiet tint of the background. Levels 1 to 3 are the accent mixed into the background at 30, 55 and 80 percent, and level 4 is the accent itself.
  • Rare and epic are the accent's neighbours on the colour wheel, 48 degrees to each side. Legendary is gold, or a light tint of the accent when the accent is already gold.
  • On the light background the rarity colours are deepened so they still read.
  • randomAccent() gives a bright, saturated colour at a random hue that reads on both backgrounds.

resolvePalette

resolvePalette(config) turns the three config keys into a WrappedPalette: the colours as an object. The stage writes it out as CSS variables, and the scan, the gallery and the card renderers take it as a prop, which is how they wear the theme of the video.

KeyTypeWhat it is
bgstringThe frame's background. --wr-bg on the stage.
cardstringA panel standing on the background. --wr-card.
fgstringText. --wr-fg.
mutedstringQuieter text. --wr-muted.
levelsreadonly [string, string, string, string, string]Five steps, quietest to loudest. --wr-l0 to --wr-l4. The last one is the accent.
rarityRecord<Rarity, string>A colour per rarity. --wr-common, --wr-rare, --wr-epic and --wr-legendary.
tsx
import { DEFAULT_WRAPPED_CONFIG, paletteStyle, resolvePalette } from "@react-wrapped/core";

const palette = resolvePalette({ ...DEFAULT_WRAPPED_CONFIG, palette: "custom", accent: "#ff5c8a" });

// The same colours as CSS variables, for an element of your own that should match the frame.
export const frameColours = paletteStyle(palette);

The built-in palettes are in WRAPPED_PALETTES, each with a label. themePalette(id, mode) gives one of them in dark or light, for a picker's swatch, and isPresetPalette(id) tells a built-in id from custom. The variables themselves are listed under theming.

Chrome tokens

Everything outside the frame is painted with these. The library sets its own values on the page's root with no weight at all, so any rule of yours wins: on :root, on body, or on a wrapper around one part.

TokenWhat it paints
--wr-ui-bgThe background of a full screen player, and text standing on a solid button.
--wr-ui-fgText, and the solid buttons.
--wr-ui-cardPanels and cards: the settings' swatches, the timeline's hover card, dialogs.
--wr-ui-mutedQuiet fills: chips at rest, the empty part of the timeline.
--wr-ui-muted-fgQuiet text: the clock, hints, descriptions.
--wr-ui-primaryThe one accent: the played part of the timeline, the highlighted words in headings.
--wr-ui-primary-fgText on the accent.
--wr-ui-borderHairlines and outlines.
--wr-ui-ringThe focus ring.
--wr-ui-radiusThe base corner radius.
--wr-fontThe chrome's font. Left out, the chrome uses the font of the page around it.
css
:root {
  --wr-ui-primary: oklch(0.62 0.2 285);
  --wr-ui-card: oklch(0.97 0.02 285);
  --wr-ui-border: oklch(0.86 0.05 285);
  --wr-ui-radius: 6px;
  --wr-font: "Your Font", sans-serif;
}

/* Your rule on :root also beats the library's dark values, so say what dark should be. */
.dark {
  --wr-ui-primary: oklch(0.74 0.16 285);
  --wr-ui-card: oklch(0.25 0.03 285);
  --wr-ui-border: oklch(0.34 0.05 285);
}

The same parts with and without a set of tokens on the wrapper.

own-chrome.tsx
import { type CSSProperties, useState } from "react";

import type { SceneInstance, WrappedConfig, WrappedSubject } from "@react-wrapped/core";
import { WrappedConfigurator, WrappedPlayer } from "@react-wrapped/player";

// Set on any ancestor of the library's parts. Each one falls back to the library's own value when left out.
const BRAND = {
  "--wr-ui-bg": "oklch(0.985 0.012 285)",
  "--wr-ui-primary": "oklch(0.62 0.2 285)",
  "--wr-ui-card": "oklch(0.97 0.02 285)",
  "--wr-ui-muted": "oklch(0.93 0.03 285)",
  "--wr-ui-border": "oklch(0.86 0.05 285)",
  "--wr-ui-fg": "oklch(0.25 0.08 285)",
  "--wr-ui-muted-fg": "oklch(0.48 0.08 285)",
  "--wr-font": "Georgia, serif",
} as CSSProperties;

// Your page around the parts can read the same tokens, so the two always match.
const COLUMNS: CSSProperties = {
  display: "flex",
  flexWrap: "wrap",
  gap: 24,
  alignItems: "flex-start",
  marginTop: 14,
  padding: 16,
  borderRadius: 18,
  background: "var(--wr-ui-bg)",
  color: "var(--wr-ui-fg)",
};
const ROOM: CSSProperties = { display: "grid", height: 520, flex: "1 1 260px", minWidth: 0 };
const PANEL: CSSProperties = { flex: "1 1 300px", minWidth: 0 };

export function OwnChrome({
  subject,
  seed,
  scenes,
  config,
  onConfigChange,
}: {
  subject: WrappedSubject;
  seed: string;
  scenes: readonly SceneInstance[];
  config: WrappedConfig;
  onConfigChange: (next: WrappedConfig) => void;
}) {
  const [branded, setBranded] = useState(true);
  return (
    <div>
      <label>
        <input type="checkbox" checked={branded} onChange={(event) => setBranded(event.target.checked)} />
        Use our tokens
      </label>
      {/* The video keeps its own palette: the tokens only reach the chrome around it. */}
      <div style={branded ? { ...COLUMNS, ...BRAND } : COLUMNS}>
        <div style={ROOM}>
          <WrappedPlayer subject={subject} seed={seed} scenes={scenes} config={config} />
        </div>
        <div style={PANEL}>
          <WrappedConfigurator config={config} onChange={onConfigChange} />
        </div>
      </div>
    </div>
  );
}

Past colour: slots

The tokens change colours, the radius and the font. To change the shape or the size of one part, such as the play button or a settings chip, give that part a class of your own. Every part of the chrome is a slot with a name: it carries data-wr-slot="player-play" and takes a class through classNames. The styling guide lists every slot, and says how to make Tailwind utilities win over the library's classes.

Dark mode

The chrome is dark wherever an ancestor has the class dark, the way most apps switch themes. There is nothing to pass and nothing to call: toggle the class on html and the library follows. A player in full screen is always dark, whatever the page is.

What the tokens do not touch

  • The frame. Its colours are the palette's, so an exported video looks the same on any site.
  • The scan panel and the gallery tiles. They sit on the palette's background on purpose, so the year looks the way it does in the video.
  • The frame's font. The stage, the share card and the insight cards are set in a font named Inter, with figures of equal width so a counting number does not shiver. See installation for loading it.

Last updated . Read this page as Markdown.