25
Componentregistry:component

Duotone & Print Filter

Two-ink colour treatment for photos in the spirit of risograph and screen prints: seven ink presets or any two colours, a mix slider and optional misregistration that can slide into register when the image scrolls into view or as you scroll. Pure SVG filters.

Installation

$ npx shadcn@latest add @creative-ui/duotone-image

On an older shadcn CLI, use the URL: npx shadcn@latest add https://ui.krutikraut.com/r/duotone-image.json

Preview

Lakeside landscape painting
Inks
#1d3fb0 → #ffe0e6
Mix1.00
Contrast1.1
Misregistration3px

Usage

Example.tsx
import Image from "next/image"
import { DuotoneImage } from "@/components/duotone-image"

export function Cover() {
  return (
    <DuotoneImage
      src="https://images.unsplash.com/photo-1577720643272-265f09367456?w=900&q=80&auto=format&fit=crop"
      alt="Woman carrying a water jar"
      preset="riso"
      offset={3}
      className="aspect-square w-72 rounded-xl"
    />
  )
}

// Slide the shadow pass into register the first time it scrolls into view.
<DuotoneImage src="/poster.jpg" preset="fluoro" offset={2} registration="view" className="aspect-[4/5] w-72" />

// Or filter an element you already render:
<DuotoneImage shadow="#0a2a6e" highlight="#dbe7f5" className="w-72">
  <Image src="/portrait.jpg" alt="Portrait" width={600} height={600} />
</DuotoneImage>

Props

PropTypeDefaultDescription
srcstring—Image URL. Omit when wrapping your own element with children.
altstring""Accessible description. Leave empty for decorative images.
preset"riso" | "newsprint" | "cyanotype" | "sepia" | "fluoro" | "forest" | "midnight""riso"A named ink pair. Overridden by shadow and highlight.
shadowstringfrom presetInk for the shadows. Hex or rgb() only.
highlightstringfrom presetInk for the highlights. Hex or rgb() only.
mixnumber1Strength of the effect over the original, 0 to 1. Ignored when wrapping children.
contrastnumber1.1Tone contrast before inking, roughly 0.5 to 2.
offsetnumber0Riso-style misregistration: offsets a second pass of the shadow ink by this many px.
offsetAnglenumber30Direction of the misregistration in degrees.
registration"static" | "view" | "scroll""static"Animate the shadow pass into register, like a riso pull. view slides it in once, the first time the image scrolls into view. scroll ties it to the image's position in the viewport. It lands on offset. Needs src.
registrationOffsetnumber24How far out of register the pass starts, along offsetAngle, in px. It also starts turned by about 0.06° per px.
registrationDurationnumber1200Length of the view slide in ms.
fit"cover" | "contain""cover"How the image fills the box.
imageClassNamestring—Class names for the img element.
childrenReact.ReactNode—Filter an existing element, such as next/image, instead of rendering an img.

Also accepts every div prop, which is spread onto the root element.

Also exported

duotonePresets
The seven ink pairs as { shadow, highlight } hex values, for building your own picker.
parseColor(input)
Parses #rgb, #rrggbb, #rrggbbaa, rgb() and rgba() into 0 to 1 channels, or null.

Notes

  • Inks are baked into an SVG filter, so CSS variables and named colours are not supported. An unreadable shadow falls back to black and an unreadable highlight to white.
  • offset adds a second copy of the image. mix below 1 adds one more. offset and registration need src and are ignored when wrapping children.
  • With registration, the pass travels on transform and opacity only. view is a CSS transition triggered once; scroll writes the transform from requestAnimationFrame while the image is on screen, with no React renders.
  • With reduced motion the pass is in register from the first paint. To replay view, remount with a new key.
  • It is a client component because of the registration animation. With the default registration="static" it renders the same markup on the server and adds no observers. Import duotonePresets and parseColor from client code.

Dependencies

shadcn/utils