Fine-art printers · Providence, since 2011

Museum prints,
made slowly

We print the world's great public-domain paintings, and editions for living artists, on cotton paper with pigment inks. Every sheet is checked by hand against a calibrated proof before it leaves the studio.

Screen print: five dark stones float on flat teal water under a coral sky.
Now printingInes Okafor, Still, five stones, 2024, from the series Five Tempers of Water.

01 Editions

This season's open editions

Four paintings we keep coming back to, each proofed against the museum's own photograph.

Van Gogh's Starry Night: swirling blue night sky over a village, a dark cypress in the foreground.

Vincent van Gogh, 1889

The Starry Night

Printed on baryta for the depth of the night blues.

From $180 · 4 sizes

Klimt's The Kiss: a couple embrace in robes of gold patterns on a flowered meadow.

Gustav Klimt, 1908

The Kiss

A warm cotton rag that lets the gold read as gold.

From $220 · 3 sizes

Van Gogh's Almond Blossom: white blossoms on twisting branches against a turquoise sky.

Vincent van Gogh, 1890

Almond Blossom

Our hardest blue, matched over eleven proofs.

From $180 · 4 sizes

02 How we print

Three rules, no shortcuts

The museum's file, untouched

We start from the largest file the collection publishes and never sharpen, upscale or "improve" it. Our job is to translate the painting, not to restore it.

Paper chosen for the painting

Baryta for dark oils, matte cotton for drawings and prints, warm rag for gold. Each edition names its paper, and we keep a swatch book you can ask to see.

A proof for every edition

Before an edition opens, a printer proofs it under daylight lamps against a calibrated screen. Most editions take three proofs; Almond Blossom took eleven.

03 The studio

Fifteen years at the bench

Prints made by hand
48,200
Open editions
64
Living artists printed
31
Returned for colour
0.4%

04 In their words

What artists say

I sent them a print I had pulled by hand and asked for an edition of thirty. What came back was indistinguishable from my own, except that every one of them was good.

Ines Okafor Printmaker, Five Tempers of Water

05 Visit

Come and see the proofs

The studio is open on Saturdays. Bring a painting you love and we will show you how it would print.

Where

Northlight Editions
212 Wickenden Street
Providence, RI 02903

When

Saturdays, 10 am to 4 pm.
Weekdays by appointment.

Ask first

Groups of more than six, please write to us so we can clear the bench.

One letter a month, when a new edition opens

No sales, no countdowns. Just the painting, the paper we chose and why.

Read the Journal first

* * Measure-once mode, [data-fit] on a .display or .hero__headline: once the * fonts are ready, the widest line (lines are the author's
breaks) is * measured in em and written to --fit. From then on CSS alone sizes the * headline to fill its column at every width (src/core/base.css), so there * is no resize listener. Without JS the author's --fit, or the default 8, * holds. * *

Make the
quiet thing
loud.

* * Dynamic content: call window.fluidFitText() after injecting * elements (idempotent; safe to call repeatedly). */ (function () { 'use strict'; var MIN_SCALE = 0.55; function fit(el) { // Reset to the authored size before measuring el.style.fontSize = ''; var base = parseFloat(getComputedStyle(el).fontSize); if (!base || el.clientWidth === 0) return; var size = base; var floor = base * MIN_SCALE; var guard = 24; while (el.scrollWidth > el.clientWidth && size > floor && guard--) { size = Math.max(floor, size - Math.max(1, base * 0.04)); el.style.fontSize = size + 'px'; } } // The lines held on one line each: the widest line's width over the font // size, plus 3% for side bearings and rounding. All headlines are set to // one line first, then all are read, then all are restored and written: // one forced layout for the page, not one per headline. function measureAll() { var els = Array.prototype.slice.call(document.querySelectorAll('[data-fit]')); var saved = els.map(function (el) { var s = el.style, kept = [s.whiteSpace, s.inlineSize]; s.whiteSpace = 'nowrap'; s.inlineSize = 'max-content'; return kept; }); var reads = els.map(function (el) { var range = document.createRange(); range.selectNodeContents(el); return [range.getBoundingClientRect().width, parseFloat(getComputedStyle(el).fontSize)]; }); els.forEach(function (el, i) { var s = el.style, w = reads[i][0], size = reads[i][1]; s.whiteSpace = saved[i][0]; s.inlineSize = saved[i][1]; if (w && size) s.setProperty('--fit', (w / size * 1.03).toFixed(3)); }); } function fitAll() { document.querySelectorAll('[data-fit-text]').forEach(fit); } var pending; function onResize() { clearTimeout(pending); pending = setTimeout(fitAll, 100); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', fitAll); } else { fitAll(); } window.addEventListener('resize', onResize); // Re-fit when fonts finish loading (metrics change); measure [data-fit] // only then, so the measurement uses the real face. if (document.fonts && document.fonts.ready) { document.fonts.ready.then(function () { measureAll(); fitAll(); }); } else { measureAll(); } window.fluidFitText = function () { measureAll(); fitAll(); }; })(); * * Dynamic content: call window.fluidToc() after injecting a TOC or headings * (idempotent; safe to call repeatedly). */ (function () { 'use strict'; var WIDE = '(min-width: 70rem)'; var TALL = '(min-height: 32rem)'; var navs = []; var queued = false; // Where a TOC jump settled (scrollY), or null while it is still moving. var pinY = null, settling = 0; /* ---------- Builder (keep identical to toc-build.mjs) ---------- */ function esc(s) { return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } // github-slugger's rule (what Astro uses): lower case, drop punctuation and // symbols, spaces to hyphens; a repeat gets -1, -2… function slug(text, used) { var base = String(text).toLowerCase().replace(/[^\p{L}\p{M}\p{N}\p{Pc}\- ]/gu, '').replace(/ /g, '-'); var id = base, n = 0; while (used[id]) id = base + '-' + (++n); used[id] = true; return id; } function tocBody(headings, opts) { opts = opts || {}; var depth = opts.depth || 3, min = opts.min == null ? 3 : opts.min; var labelId = opts.labelId || 'toc-label', label = opts.label || 'On this page'; var tree = [], last = null, skipping = false; headings.forEach(function (h) { if (h.depth === 2) { skipping = Boolean(h.skip); if (skipping) return; last = { h: h, kids: [] }; tree.push(last); } else if (h.depth === 3 && depth >= 3 && last && !skipping && !h.skip) { last.kids.push({ h: h }); } }); if (tree.length < min) return ''; function item(n) { var a = '' + esc(n.h.label || n.h.text) + ''; return '
  • ' + a + (n.kids && n.kids.length ? '
      ' + n.kids.map(item).join('') + '
    ' : '') + '
  • '; } return '
    ' + esc(label) + '
      ' + tree.map(item).join('') + '
    '; } // Headings as toc-build.mjs takes them, read from the page. function readHeadings(root) { var used = {}; Array.prototype.forEach.call(document.querySelectorAll('[id]'), function (el) { used[el.id] = true; }); return Array.prototype.map.call(root.querySelectorAll('h2, h3'), function (el) { var text = el.textContent.replace(/\s+/g, ' ').trim(); if (!el.id) el.id = slug(text, used); return { depth: el.tagName === 'H2' ? 2 : 3, id: el.id, text: text, label: el.getAttribute('data-toc-label') || undefined, skip: el.hasAttribute('data-toc-skip') || undefined }; }); } function build(nav) { var src = nav.getAttribute('data-toc-source'); var root = (src ? document.querySelector(src) : nav.closest('article') || document.querySelector('main')) || document.body; var labelId = nav.getAttribute('aria-labelledby') || 'toc-label'; var label = nav.getAttribute('data-toc-label-text'); var html = tocBody(readHeadings(root), { depth: Number(nav.getAttribute('data-toc-depth')) || 3, min: nav.hasAttribute('data-toc-min') ? Number(nav.getAttribute('data-toc-min')) : 3, labelId: labelId, label: label }); nav.innerHTML = html; nav.hidden = !html; if (html) nav.setAttribute('aria-labelledby', labelId); } /* ---------- Spy and dock ---------- */ function reduced(nav) { return matchMedia('(prefers-reduced-motion: reduce)').matches || Boolean(nav.closest('[data-motion="reduced"]')); } // Scroll a row (never the page) so el's centre meets the row's centre. function centre(row, el, nav, instant) { if (!row || !el || !row.clientWidth) return; var r = row.getBoundingClientRect(), e = el.getBoundingClientRect(); var dx = (e.left + e.width / 2) - (r.left + r.width / 2); if (Math.abs(dx) < 1) return; row.scrollBy({ left: dx, behavior: instant || reduced(nav) ? 'instant' : 'smooth' }); } function centreRows(t, instant) { if (t.mode !== 'dock') return; var cur = t.active >= 0 ? t.links[t.active] : null; var rowOne = t.nav.querySelector('.toc__fold > ol'); var top = cur ? cur.closest('.toc__fold > ol > li') : rowOne && rowOne.firstElementChild; if (!top) return; // The room either side of the centred entry, less the 1.5rem fade: a // neighbour capped to it (CSS, phones) ends in its ellipsis on screen. var fade = parseFloat(getComputedStyle(document.documentElement).fontSize) * 1.5; t.nav.style.setProperty('--_toc-side', Math.max(0, (rowOne.clientWidth - top.firstElementChild.offsetWidth) / 2 - fade) + 'px'); centre(rowOne, top.firstElementChild, t.nav, instant); var rowTwo = top.querySelector(':scope > ol'); if (rowTwo && cur) centre(rowTwo, cur.parentElement.parentElement === rowTwo ? cur : rowTwo.firstElementChild.firstElementChild, t.nav, instant); } // Where the spy says the reader is: an index into t.heads, or -1. Reads only. function activeIndex(t) { if (t.pin != null) return t.pin; var h = innerHeight, left = document.documentElement.scrollHeight - (scrollY + h); // Measured in the space the dock leaves, so the line never sits under it. var dock = t.mode === 'dock' && t.nav.hasAttribute('data-toc-visible') ? t.nav.offsetHeight : 0; var line = (h - dock) * (0.2 + 0.75 * Math.max(0, 1 - left / h)); var active = -1; for (var i = 0; i < t.heads.length; i++) { if (t.heads[i].getBoundingClientRect().top <= line) active = i; else break; } return active; } // Writes only; true when the active link changed. function apply(t, active) { if (!t.links[active]) active = -1; // a stale pin after the headings changed if (active === t.active) return false; if (t.active >= 0 && t.links[t.active]) t.links[t.active].removeAttribute('aria-current'); if (active >= 0) t.links[active].setAttribute('aria-current', 'location'); t.active = active; return true; } // The rail keeps its active entry in view when the list is taller than it. function railKeep(t) { if (t.mode !== 'rail' || t.active < 0 || t.nav.scrollHeight <= t.nav.clientHeight) return; var n = t.nav.getBoundingClientRect(), a = t.links[t.active].getBoundingClientRect(); var d = a.top < n.top ? a.top - n.top - 8 : a.bottom > n.bottom ? a.bottom - n.bottom + 8 : 0; if (d) t.nav.scrollBy({ top: d, behavior: 'instant' }); } function dockSize(t) { var root = document.documentElement.style; if (t.mode === 'dock') root.setProperty('--_toc-dock-size', t.nav.offsetHeight + 'px'); else if (!navs.some(function (o) { return o.mode === 'dock'; })) root.removeProperty('--_toc-dock-size'); } // Every nav is read first, then every nav is written; the centring reads // again in the next frame. No layout is forced between a write and a read. function spyAll() { var next = navs.map(activeIndex); var changed = navs.filter(function (t, k) { return apply(t, next[k]); }); if (changed.length) { requestAnimationFrame(function () { changed.forEach(function (t) { centreRows(t, false); railKeep(t); }); }); } } function setMode(t) { var words = (t.nav.getAttribute('data-toc') || '').split(/\s+/); var rail = words.indexOf('rail') > -1, wide = matchMedia(WIDE).matches; var dock = words.indexOf('dock') > -1 && matchMedia(TALL).matches; var mode = dock && (!rail || !wide) ? 'dock' : rail && wide ? 'rail' : 'inline'; if (mode === t.mode) return; t.mode = mode; // Switch without the dock's slide, so a page never loads with the bar // gliding away; data-toc-instant turns the transition off for two frames. t.nav.setAttribute('data-toc-instant', ''); t.nav.setAttribute('data-toc-mode', mode); var fold = t.nav.querySelector('.toc__fold'); if (fold) { if (mode !== 'inline') fold.open = true; else if (rail) fold.open = false; } requestAnimationFrame(function () { centreRows(t, true); dockSize(t); requestAnimationFrame(function () { t.nav.removeAttribute('data-toc-instant'); }); }); } function onScroll() { // Any scroll that moves a quarter of a screen from where a jump settled // releases the pin, however it was made (scrollbar, Tab, find, script). if (pinY != null && Math.abs(scrollY - pinY) > innerHeight / 4) unpin({ type: 'scroll' }); if (queued) return; queued = true; requestAnimationFrame(function () { queued = false; spyAll(); }); } function onResize() { navs.forEach(setMode); onScroll(); } function idOf(hash) { var raw = String(hash).replace(/^#/, ''); try { return decodeURIComponent(raw); } catch (e) { return raw; } } // A TOC click or a hash change: the target is current until the reader scrolls. function pin(id) { navs.forEach(function (t) { var i = -1; for (var k = 0; k < t.heads.length; k++) if (t.heads[k].id === id) { i = k; break; } t.pin = i >= 0 ? i : null; }); settle(); onScroll(); } // Record where the jump lands: the first scrollY that holds still for // three frames (a smooth scroll moves every frame), at most ~5s. function settle() { pinY = null; var run = ++settling, last = scrollY, quiet = 0, frames = 0; (function tick() { requestAnimationFrame(function () { if (run !== settling) return; if (Math.abs(scrollY - last) < 1) quiet++; else { quiet = 0; last = scrollY; } if (quiet >= 3 || ++frames > 300) pinY = scrollY; else tick(); }); })(); } function unpin(e) { if (e.type === 'keydown' && !/^(ArrowUp|ArrowDown|PageUp|PageDown|Home|End| )$/.test(e.key)) return; var any = false; navs.forEach(function (t) { if (t.pin != null) { t.pin = null; any = true; } }); pinY = null; settling++; if (any) onScroll(); } function init(nav) { if (nav.hasAttribute('data-toc-build') && !nav.querySelector('a')) build(nav); var t = nav._fluidToc; if (!t) { t = nav._fluidToc = { nav: nav, active: -1, mode: null, pin: null }; navs.push(t); // A link reached by Tab in the dock is centred, so it is never under // the fade; a row-one link also brings its row two to the start. A // pointer press focuses a link too (Chrome), but is skipped. nav.addEventListener('focusin', function (e) { if (t.mode !== 'dock' || !e.target.matches('a')) return; var keyboard = true; try { keyboard = e.target.matches(':focus-visible'); } catch (err) { /* old browser: assume keyboard */ } if (!keyboard) return; centre(e.target.closest('ol'), e.target, nav, true); var sub = e.target.nextElementSibling; if (sub && sub.tagName === 'OL' && sub.firstElementChild) centre(sub, sub.firstElementChild.firstElementChild, nav, true); }); nav.addEventListener('click', function (e) { var a = e.target.closest && e.target.closest('a[href^="#"]'); if (a) pin(idOf(a.hash)); }); } // The dock shows while the article body is on screen. Re-observe if the // body changed (the page swapped it out). var body = nav.closest('.article__body') || nav.parentElement; if (t.body !== body) { if (t.io) t.io.disconnect(); t.body = body; if ('IntersectionObserver' in window) { t.io = new IntersectionObserver(function (entries) { var on = entries[entries.length - 1].isIntersecting; nav.toggleAttribute('data-toc-visible', on); if (on) centreRows(t, true); }); t.io.observe(body); } else { nav.setAttribute('data-toc-visible', ''); } } // The nav's own links decide the headings it follows. t.links = []; t.heads = []; Array.prototype.forEach.call(nav.querySelectorAll('a[href^="#"]'), function (a) { var h = document.getElementById(idOf(a.hash)); if (h) { t.links.push(a); t.heads.push(h); } }); t.active = -1; t.pin = null; // fluidToc() pins location.hash again after init Array.prototype.forEach.call(nav.querySelectorAll('[aria-current]'), function (a) { a.removeAttribute('aria-current'); }); t.mode = null; setMode(t); apply(t, activeIndex(t)); } var listening = false; function fluidToc() { // Drop navs that left the page (an SPA or view-transition swap), with // their observers, so detached headings are never read again. navs = navs.filter(function (t) { if (t.nav.isConnected) return true; if (t.io) t.io.disconnect(); delete t.nav._fluidToc; return false; }); Array.prototype.forEach.call(document.querySelectorAll('nav.toc'), init); if (!listening) { listening = true; addEventListener('scroll', onScroll, { passive: true }); addEventListener('resize', onResize, { passive: true }); addEventListener('hashchange', function () { pin(idOf(location.hash)); }); ['wheel', 'touchstart', 'keydown'].forEach(function (ev) { addEventListener(ev, unpin, { passive: true }); }); [WIDE, TALL].forEach(function (q) { var mq = matchMedia(q); if (mq.addEventListener) mq.addEventListener('change', onResize); }); } if (location.hash) pin(idOf(location.hash)); } window.fluidToc = fluidToc; // Exposed for toc-check.mjs, which proves this builder and toc-build.mjs agree. window.fluidToc.html = tocBody; if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', fluidToc); } else { fluidToc(); } })();