npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

image-to-theme-colors

v4.2.0

Published

Compose accessible UI themes (article body/card + affirmation overlays) from any image

Downloads

466

Readme

image-to-theme-colors

Compose accessible UI themes from any image. The library exposes two functions tuned to two product surfaces:

  • composeArticleTheme — for an article system whose open-state body uses the image as a hero transitioning into a colored background, plus a closed-state feed card and a circular control (e.g. a like button) drawn on top.
  • composeAffirmationTheme — for an affirmation card whose entire backdrop is the image, with a category label pinned to the top and circular controls (Share, Bookmark, More) at the bottom.

For articles, the algorithm analyzes the image's color composition and outputs:

  • body.background — solid color and gradient for the open-state article background, with WCAG AAA (7:1) contrast against your text colors.
  • card.background — solid color and gradient for the feed card surface, with sufficient contrast against your feed background (1.15:1 light, 1.12:1 dark).
  • card.content.accentColor — color for circular controls / icons sitting on the card. Mirrors body.content.labelColor so the open-state and feed-state palettes stay coherent; clears WCAG AA (4.5:1) against the card surface.

…all on a shared hue per theme so the body, card, icon, and text read as one color family.

For affirmations, the algorithm samples the image's top and bottom regions (the overlays' underlying surfaces) and returns the same themes.{light,dark} shape, with overlays nested under the same card.content namespace the article uses for its closed-state surface:

  • card.content.labelColor — fill for the category label at the top of the card.
  • card.content.accentColor — fill for the circular controls at the bottom.

Affirmation overlays don't change with theme, so the light and dark values are identical — the wrap is preserved for API parity with composeArticleTheme.

Article themes (light and dark) composed from four different hero images

Install

npm install image-to-theme-colors

Requires Node.js 18+ and sharp (installed automatically).

Quick start

Article (open-state body + closed-state card):

import { composeArticleTheme } from "image-to-theme-colors";

const result = await composeArticleTheme("./hero.jpg");
// result.themes.light.body.background.baseColor       "#C0D0FF"
// result.themes.light.body.background.linearGradient  ["#C0D0FF", "#BAC9F9"]
// result.themes.light.card.background.baseColor       "#D5E2ED"
// result.themes.light.card.background.linearGradient  ["#D5E2ED", "#B2CADD"]
// result.themes.light.card.content.accentColor        "#214154"  // = body label
// result.themes.dark.body.background.baseColor        "#0F172F"
// …

Affirmation card (image backdrop + label + circular icons):

import { composeAffirmationTheme } from "image-to-theme-colors";

const result = await composeAffirmationTheme("./affirmation.jpg");
// result.themes.light.card.content.labelColor   "#B0C1E8"
// result.themes.light.card.content.accentColor  "#B0C1E8"
// result.themes.dark.card.content.labelColor    "#B0C1E8"  (same as light)

API

composeArticleTheme(input, options?)

Analyzes an image and returns body and card colors for light and dark themes.

Parameters:

| Parameter | Type | Description | |-----------|------|-------------| | input | string \| Buffer | File path or image buffer | | options | ArticleThemeOptions | Optional configuration |

Options:

| Option | Type | Default | Description | |--------|------|---------|-------------| | lightThemeTextColor | string | "#2A2925" | Text color on the light body background. The body background guarantees 7:1 contrast against this. | | darkThemeTextColor | string | "#FFFFFF" | Text color on the dark body background. The body background guarantees 7:1 contrast against this. | | lightThemeFeedBackgroundColor | string | "#F0F0F0" | Feed (page) background behind cards on the light theme. The card background guarantees 1.15:1 contrast against this. | | darkThemeFeedBackgroundColor | string | "#110F0E" | Feed (page) background behind cards on the dark theme. The card background guarantees 1.12:1 contrast against this. | | lightThemeCardTitleColor | string | "#2A2925" | Title text color on light-theme cards. The card guarantees 7:1 (AAA) contrast against this. | | lightThemeCardSubtitleColor | string | "#51504D" | Subtitle text color on light-theme cards. The card guarantees 6:1 contrast against this. | | darkThemeCardTitleColor | string | "#FCFCFC" | Title text color on dark-theme cards. The card guarantees 7:1 (AAA) contrast against this. | | darkThemeCardSubtitleColor | string | "#A09F9E" | Subtitle text color on dark-theme cards. The card guarantees 6:1 contrast against this. |

Returns: Promise<ArticleTheme>

interface ArticleTheme {
  themes: {
    light: ArticleThemeColors;
    dark: ArticleThemeColors;
  };
}

interface ArticleThemeColors {
  body: BodyTheme;
  card: CardTheme;
}

interface BodyTheme {
  background: BackgroundColors;
  content: BodyContent;
}

interface BodyContent {
  /**
   * Icon color inside the Liquid-Glass control at the top of the
   * article. Two values for the scroll crossfade: `overImage` while
   * the control sits over the hero, `overBody` after it scrolls onto
   * the body background. Each clears 4.5:1 (WCAG AA) against its
   * glass tint, falling back to 3:1 when the theme-appropriate side
   * has no room.
   */
  accentColor: { overImage: string; overBody: string };
  /**
   * Color of the small category label (e.g. "Article") that sits in
   * the hero-to-body transition zone. Solved against the composite of
   * the body's first gradient stop and the image's lower portion at
   * the label's vertical position. Reused as `card.content.accentColor`
   * so the open-state and feed-state palettes stay coherent.
   */
  labelColor: string;
}

interface CardTheme {
  background: BackgroundColors;
  content: {
    /**
     * Category label on the feed card surface. Same value as
     * `accentColor` — exposed under the label name for semantic
     * clarity when styling a text label rather than an icon.
     */
    labelColor: string;
    /** Circular control on the feed card. = body.content.labelColor. */
    accentColor: string;
  };
}

interface BackgroundColors {
  /** Base color / first gradient stop, e.g. `"#C0D0FF"`. */
  baseColor: string;
  /** Gradient stops, e.g. `["#C0D0FF", "#BAC9F9"]`. */
  linearGradient: [string, string];
}

composeAffirmationTheme(input, options?)

Analyzes an image and returns colors for the label (top) and circular icons (bottom) of an affirmation card. The image itself is the card's backdrop, so each overlay's color is solved against the slice of the image it sits on.

Parameters:

| Parameter | Type | Description | |-----------|------|-------------| | input | string \| Buffer | File path or image buffer | | options | AffirmationThemeOptions | Optional configuration |

Options:

| Option | Type | Default | Description | |--------|------|---------|-------------| | topRegionFraction | number | 0.25 | Fraction of image height (0.05–0.5) treated as the top region (under the label). Lower for thinner top bands when the label covers a smaller share of the image. | | bottomRegionFraction | number | 0.25 | Fraction of image height (0.05–0.5) treated as the bottom region (under the icons). |

Returns: Promise<AffirmationTheme>

interface AffirmationTheme {
  themes: {
    light: AffirmationThemeColors;
    dark: AffirmationThemeColors;  // identical to light
  };
}

interface AffirmationThemeColors {
  card: {
    content: {
      /** Color for the category label at the top (≈ article body.content.labelColor). */
      labelColor: string;
      /** Color for the circular icons at the bottom (≈ article card.content.accentColor). */
      accentColor: string;
    };
  };
}

See How composeAffirmationTheme works below for the algorithm details.

Example with a smaller top band (e.g. a label that overlaps only the top 12% of a thumbnail):

const result = await composeAffirmationTheme("./affirmation.jpg", {
  topRegionFraction: 0.12,
  bottomRegionFraction: 0.20,
});
const { labelColor, accentColor } = result.themes.light.card.content;

Examples

With custom text and feed colors:

const result = await composeArticleTheme(buffer, {
  lightThemeTextColor: "#1A1A1A",
  darkThemeTextColor: "#F0F0F0",
  lightThemeFeedBackgroundColor: "#F5F5F5",
  darkThemeFeedBackgroundColor: "#0A0A0A",
});

Using the gradient in CSS:

const { light } = (await composeArticleTheme("./hero.jpg")).themes;

// open-state body
articleEl.style.backgroundColor = light.body.background.baseColor;
articleEl.style.backgroundImage =
  `linear-gradient(to bottom, ${light.body.background.linearGradient[0]}, ${light.body.background.linearGradient[1]})`;

// feed card
cardEl.style.backgroundColor = light.card.background.baseColor;
cardEl.style.backgroundImage =
  `linear-gradient(to bottom, ${light.card.background.linearGradient[0]}, ${light.card.background.linearGradient[1]})`;

// like button on the card
likeBtnEl.style.color = light.card.content.accentColor;

From an HTTP upload (Express + multer):

app.post("/upload", upload.single("image"), async (req, res) => {
  const colors = await composeArticleTheme(req.file.buffer);
  res.json(colors);
});

Affirmation card with overlays applied in CSS:

const aff = await composeAffirmationTheme("./affirmation.jpg");
const { labelColor, accentColor } = aff.themes.light.card.content;

cardBackdropEl.style.backgroundImage = `url("./affirmation.jpg")`;
labelEl.style.color = labelColor;            // category tag at top
shareIcon.style.color = accentColor;         // circular controls at bottom
bookmarkIcon.style.color = accentColor;
moreIcon.style.color = accentColor;

Errors

Both functions reject with the underlying sharp error if the input can't be decoded (unsupported format, corrupted file, missing path). There's no input validation beyond what sharp does — pass valid image bytes or a readable file path. Fully transparent images (every pixel below the alpha threshold) will throw on the empty pixel array; this is rare in practice but worth noting if you accept arbitrary uploads.

How composeArticleTheme works

The article algorithm runs in four phases:

1. Pixel extraction — Resizes the image to 150px (preserving aspect ratio) and converts to HSL pixel data.

2. Analysis — Builds a hue histogram with extra weight on border/edge pixels (which are more likely to be the image background rather than the subject). Also detects accent colors, background tints, and bottom-edge colors for gradient transitions.

3. Strategy selection — Picks one of four approaches based on the image:

| Strategy | Trigger | Example | |----------|---------|---------| | Achromatic | Average saturation < 8% | B&W line art | | Dominant mid-tone | Clear mid-tone color dominates | Green painting, blue illustration | | Light background | Median lightness > 70% | Person on white/pastel background | | Dark background | Mostly dark with bright accent | Night sky with a star |

4. Color generation — Produces the body colors using chroma-preserving lightness adjustment, iterative S/L co-solving, and WCAG AAA contrast enforcement, then derives the card colors from the body's hue:

  • Card background: the lightest tint (light theme) / darkest shade (dark theme) that still clears the feed-background contrast budget and the title (7:1) + subtitle (6:1) contrast budgets. If those constraints conflict (text-readability requires a card too close in luminance to the feed bg), text wins and feed contrast may dip below its budget.
  • Card content (accentColor): reuses body.content.labelColor — same hue, AA (4.5:1) contrast against the body+image label-area composite. Because that composite sits between body and card bg in lightness, the value also clears AA against the card surface (typically with margin to spare).

Design decisions

  • Background-first: Border and bottom-edge pixels are weighted higher because the gradient transitions from the bottom of the image into the article text. The algorithm prioritizes the image's background color over foreground subjects.

  • Foreground detection: When a foreground object extends to the bottom edge (e.g. hands), the algorithm detects this by checking whether the bottom color is concentrated at the bottom (background) or spread through the image (foreground).

  • Multi-hue images: When the bottom edge has a distinctly different color from the dominant (e.g. green hill below blue sky), the algorithm uses the bottom color for the dark theme and the accent for the light theme.

  • Card hue follows body: The feed card never invents its own hue — it inherits from the body so that the closed-state preview, the open-state background, and the icon all read as the same color family.

How composeAffirmationTheme works

The affirmation algorithm samples the top and bottom slices of the image (each ~25% by height, configurable) and decides which mode the image is in:

  • Split when the slices' median lightness differ enough that the image reads as two zones (e.g. a sky over a ground). Label and accent are solved independently against their own slice.
  • Uniform otherwise. The label is solved against the top slice and reused as the accent, since both controls sit on the same visual character.

Within each mode the output's hue mirrors the relevant slice's dominant cluster, while its lightness and saturation are tuned so the control reads cleanly against the underlying image. A dark image yields a light pastel control; a bright vivid image yields a dark or desaturated control. Fully achromatic regions (low saturation overall, e.g. a black-and-white text page) yield a near-gray output.

EXIF orientation is honored before sampling, so phone photos saved sideways are analyzed against the visual top of the image, not the file's storage top.

Design decisions

  • Top-anchored hue under low purity: when the top slice mixes two competing hues (e.g. a horizon line cutting through it), the very topmost band gets a separate read so the label takes the cleaner dominant rather than the saturation-weighted average.
  • Identical light/dark values: affirmation overlays don't change with theme — the image itself is the same. The dual-theme wrap exists for API parity with composeArticleTheme.
  • Pathological-input fallback: when either slice is too small to summarize (extreme aspect ratios, tiny images), the algorithm collapses to a single combined-image color rather than producing a split decision from a sparse histogram.

Performance

Processing a single image takes 50–100ms on a modern CPU. The algorithm is fully CPU-bound (no GPU required).

Development

git clone <repo-url>
cd image-to-theme-colors
npm install

Run the article validation suite against the 10 reference hero images:

npm run dev:validate

Run the affirmation validation suite against the 13 reference affirmation images:

npm run dev:validate-affirmation

Start the batch preview server to test multiple images at once:

npm run dev:server
# Open http://localhost:3000

Start the card+article demo (single image, full card and open-state preview, with copy-to-clipboard hex outputs):

npm run dev:demo
# Open http://localhost:3030

Build the library:

npm run build

License

MIT — see LICENSE.