Evil Genius

Evil Genius

WISE WORDS FROM FICTIONAL VILLAINS

EDITED BY JOSHUA BLACKBURN
ILLUSTRATED BY NOMA BAR

PUBLISHED BY OFFBEAT PRESS

THE ULTIMATE COLLECTION OF VILLAIN QUOTES

OVER 1,000 QUOTATIONS

OVER 400 VILLAINS

OVER 3000 YEARS OF VILLAINY

ONLY FICTIONAL VILLAINS


WISDOM FROM THE GREATEST VILLAINS EVER IMAGINED

OVER 1,000 QUOTATIONS SPANNING 3,000 YEARS OF VILLAINY

THE BEST BADDIES OF literature, stage, screen, gaming, anime & manga

the finest collection of villain quotes ever assembled

edited by joshua blackburn, Illustrated by Noma Bar

THE GREATEST COLLECTION OF VILLAIN QUOTES EVER ASSEMBLED

over 1,000 quotations spanning 3,000 years of baddies

villiains of MYTH, Literature, stage, screen, gaming, anime & manga

THE ESSENTIAL WORK FOR AFICIONADO'S OF  FICTIONAL VILLAINS

EDITED BY JOSHUA BLACKBURN, Illustrated by Noma Bar

ILLUSTRATED BY NOMA BAR
over 1,000 quotations
over 400 villains
ARTBOOK QUALITY
A beautifully bound 250-page coffeetable book
over 1,000 quotations
Quotations from over 400 of the world's greatest fictional villains
over 30 ORIGINAL ILLUSTRATIONS
Creations by Noma Bar, the master of negative space
easy to search
Eight thematic sections and three indexes, for subject, character and title
analysis of 1,000 greatest villains
The most comprehensive census of villains ever compiled
A BOOK TO COVET
A stunningly designed, beautifully bound 248-page book
an exhaustive collection
Over 1,000 quotations from over 400 of the world's greatest fictional villains
ORIGINAL ILLUSTRATIONS
Illustrated by Noma Bar, the master of negative space
A PLEASURE TO BROWSE
Eight thematic sections and three indexes, by subject, character and title
A STUDY OF VILLAINY
The Evil Genius Villain's Census, analysing 1,000 of the greatest villains
"Perverting common wisdom is a mark of all great conspiracies."
Baron Vladimir Harkonnen
Dune
Wisdom
"The greatest trick the Devil ever pulled was convincing the world he didn't exist."
Verbal Kint
The Usual Suspects
Wisdom
"Life is a game. So fight for survival and see if you're worth it."
Teacher Kitano
Battle Royale
Life
"Yeah, now, well, the thing about the old days: they the old days."
Slim Charles
The Wire
Wisdom
"Reality is a mite bloodier than sitcoms or comics. The greys stretch out farther."
Manchester Black
Action Comics - Superman
Wisdom
"Don't ever worry about anything you can't control, ever."
Tom Ripley
The Talented Mr Ripley
Wisdom
"Fuck pride. Pride only hurts, it never helps."
Marellus Wallace
Pulp Fiction
Life
"Every man has to go through hell to reach paradise."
Max Cady
Cape Fear
Life
"Death is the only great adventure I have left."
Hook
Peter Pan
Life
"Life is not fair."
Scar
The Lion King
Life
"Nothing ever goes as planned in this accursed world."
Uchiha Madara
Naruto
Life
"I'm never finished."
Lex Luthor
Superman
Last words
"Suck my balls."
Eric Cartman
Southpark
Last words
"Live fast. Love hard. Die young. Leave a good-smelling corpse."
Catwoman
Catwoman
Last words
"Only now at the end do you understand."
Darth Vader
Star Wars – The Empire Strikes Back
Last words
"You lose. You idiot, you lose."
Daniel Plainview
There Will Be Blood
Last words
"Life is... funnier than shit."
Jo Minaldi
Once Upon a Time in America
Last words
“Those who want respect give respect.”
Tony Soprano
The Sopranos
People
"There are no saints in the animal kingdom - only breakfast and dinner."
Lorne Malvo
Fargo - Season 1
People
"Nobody works with me. People work for me."
Billy Kimber
Peaky Blinders
People
"Great men are not born great, they grow great."
Vito Corleone
The Godfather
“Those who want respect give respect.”
Tony Soprano
The Sopranos
"We are who we choose to be... now, choose!"
Green Goblin
Spider-Man
People
"French people piss me off."
Eric Cartman
Southpark
Bon mots
"What's not to like?"
Lucifer
Lucifer
Bon mots
"Come the fuck in or fuck the fuck off."
Malcolm Tucker
The Thick of It
Bon mots
"Politicians, ugly buildings and whores all get respectable if they last long enough."
Noah Cross
Chinatown
Bon mots
"When I've raised hell you'll know it."
Tom Reagan
Miller's Crossing
Bon mots
"I find you lack of faith disturbing."
Darth Vader
Star Wars - The Empire Strikes Back
Bon mots
"Outnumbered? Outclassed is more like it."
Evil-Lyn
Masters of the Universe
Bon mots
"I'd like to thank all the little people I stepped on to get where I am today."
Brain
Pinky and the Brain
Business
"All I want is for everything to be perfect."
President Business
The Lego Movie
Business
"Money has no moral opinions."
Joe orse
Force of Evil
Business
"Do it first. Do it yourself. And keep on doing it."
Tony Camonte
Scarface
Business
"A lawyer with a briefcase can steal more than a hundred men with guns."
Michael Corleone
The Godfather
Business
"Is it wrong if I cheat to win a million-dollar bet?"
C. Montgomery Burns
The Simpsons
Business
"Caution is for road signs."
Blake
Dynasty
Character
"I am my own creation."
Lex Luthor
Action Comics
Character
"You'll see what I can do."
Daniel Plainview
There Will Be Blood
Character
"My name is my name."
Marlo Stanfield
The Wire
Character
"I always thought it'd be better to be a fake somebody than a real nobody."
Tom Ripley
The Talented Mr Ripley
Character
"You only get one shot at life. Why waste it on sleep?"
Graves
Die Another Day
Character
"I only lie when it's necessary."
Milo Minderbinder
Catch-22
Morality
"I don't make the rules. I just think them up and write them down."
Eric Cartman
Southpark
Morality
"Once you've given up integrity, the rest is a piece of cake."
J. R. Ewing
Dallas
Morality
"A man's gotta have a code."
Omar
The Wire
Morality
"It's not a lie if you believe it."
George Costanza
Seinfeld
Morality
"Life's too slippery for books, Clarice. Typhoid and swans came from the same God."
Hannibal Lecter
Silence of the Lambs
Bon mots
"Success, real success, is about being willing to do the things other people aren't."
Stonewall
Expendables 3
Business
"I wasted time, and now doth time waste me."
Richard II
Richard II
Last words
"A lot can happen between now and never."
Petyr Baelish
Game of Thrones
Life
"Hatred is always more clear-sighted and ingenious than friendship"
Marquise de Merteuil
Dangerous Liaisons
People
"I don't want to do good things, I want to do great things."
Lex Luthor
Superman
Character
“The distance between insanity and genius is measured only by success.”
Elliot Carver
Tomorrow Never Dies
Character

PICK A THEME

PICK A THEME
CHARACTER
LIFE
BUSINESS
WISDOM
PEOPLE
MORALITY
BON MOTS
LAST WORDS
This is some text inside of a div block.
This is some text inside of a div block.
This is some text inside of a div block.

published by offbeat press

EDITED BY

JOSHUA BLACKBURN

Joshua Blackburn is an author, designer and Maker of Things. He is the creator and Question-Writer-in-Chief of League of the Lexicon, the most successful word game in Kickstarter history. He is also the author of The Language-Lover's Lexipedia (Avid Reader Press / Bloomsbury 2025), and the League of the Lexicon Junior Edition (Two Brothers Games, 2023).
As well as being a boardgame publisher and accidental linguist, Joshua is a photographer, whose book Launderama (Hoxton Mini Press, 2018) is a beautiful portrait of London's endangered launderettes.
ILLUSTRATED BY

NOMA BAR

Noma Bar is an acclaimed graphic artist and illustrator known for his masterful use of negative space and minimalist visual wit. Based in London, he studied graphic design at the Bezalel Academy of Art and Design before developing a distinctive style that merges bold simplicity with conceptual depth. His work has appeared in publications such as The New Yorker, The Guardian, and Time Out, and for brands including Apple, Coca-Cola, and IBM. Through his precise balance of form and meaning, Noma transforms everyday imagery into layered visual metaphors, often political, satirical, or deeply human, earning him international recognition and multiple design awards.

SIGN UP FOR PUBLICATION NEWS

(function () { var FRAME_SETS = { large: 'https://jaybee212.github.io/evil-genius-frames-FINAL/web-1400/', small: 'https://jaybee212.github.io/evil-genius-frames-FINAL/web-900/' }; var PHONE_SHORT_EDGE = 500; var isPhone = Math.min(window.innerWidth, window.innerHeight) <= PHONE_SHORT_EDGE; var FRAME_BASE = isPhone ? FRAME_SETS.small : FRAME_SETS.large; var FRAME_NAME = function (n) { return 'book_' + String(n).padStart(3, '0') + '.webp'; }; var FRAME_COUNT = 120; var SCRUB = 0.4; var STILL_FRAME = 90; var BG_ASPECT = 16 / 9; var BG_FLOOR = 0.78; /* How much of each frame file is empty space around the book. These are fallbacks - the script measures the real frame at load and replaces them, so you should not need to touch them. */ var INSET = { left: 0.15, right: 0.15, top: 0.02, bottom: 0.095 }; var HEADLINE_LINES = ['THE ULTIMATE', 'COLLECTION OF', 'VILLAIN QUOTES']; /* The four messages. None of them may ever wrap - that rule is what holds the whole text block down to a sensible size. */ var MESSAGE_SEL = '.message1, .message-1, .message2, .message-2, ' + '.message3, .message-3, .message4, .message-4'; var GRID = { COLS: 8, GUTTER: 0.5, BOOK_FROM: 2, BOOK_TO: 4, TEXT_FROM: 5, TEXT_TO: 7, BOOK_MAX_H: 0.78, TOP_CLEAR: 0.16, // clear space above the book. THIS is what stops it riding high. MAX_WIDTH: 1800, // the grid stops growing past this and centres itself NARROW_W: 900, // landscape windows narrower than this give the text // ALL the space beside the book, not columns 5-7 NARROW_PAD: 0.05 // clear space above and below the text block there }; var TEXT = { MIN_PX: 18, MAX_PX: 110, MSG_MIN_PX: 14, // landscape: messages never smaller than this, // whatever the headline does <- READABILITY FLOOR FILL: 1.0, // headline fills this much of its column span MAX_VS_BOOK: 1.0, // ...and is never wider than the book x this MSG_RATIO: 0, // message size as a fraction of headline size. 0 = copy whatever your CSS does.. Raise to let the text dominate. SIDE_PAD: 0.05, STACK_GAP: 0.045 }; /* Space under the headline in landscape, as a multiple of the headline's own size. PADDING, not margin - padding is part of the element's box and cannot be collapsed or overridden by anything. THIS IS THE ONE NUMBER TO TURN: bigger = more air. */ var HEADLINE_PAD = 1.0; /* Measure the transparent border of a frame, so the BOOK fills the columns rather than the file. Runs once; silently keeps the fallbacks above if the browser will not let us read the pixels. */ function measureInset(url, done) { try { var im = new Image(); im.crossOrigin = 'anonymous'; im.onload = function () { try { var W = 220, H = Math.max(1, Math.round(im.naturalHeight * W / im.naturalWidth)); var cv = document.createElement('canvas'); cv.width = W; cv.height = H; var c2 = cv.getContext('2d'); c2.drawImage(im, 0, 0, W, H); var d = c2.getImageData(0, 0, W, H).data; var minX = W, maxX = -1, minY = H, maxY = -1; for (var y = 0; y < H; y++) { for (var x = 0; x < W; x++) { if (d[(y * W + x) * 4 + 3] > 12) { if (x < minX) minX = x; if (x > maxX) maxX = x; if (y < minY) minY = y; if (y > maxY) maxY = y; } } } if (maxX < 0 || maxX - minX < W * 0.2) return; done({ left: minX / W, right: (W - 1 - maxX) / W, top: minY / H, bottom: (H - 1 - maxY) / H }); } catch (e) { /* pixels not readable - keep fallbacks */ } }; im.onerror = function () {}; im.src = url; } catch (e) {} } /* ---------- PORTRAIT (square and narrower) ---------- The BOOK keeps the 7-row grid: cover top on the 2/3 line, spine base on the 6/7 line. The TEXT no longer uses the grid at all - the headline is centred in whatever space is above the book, the messages in whatever space is below it. They follow the book. STAND_ON_FLOOR false = base sits on the 6/7 line, as drawn. true = base sits on the floor line in the photo. */ var PORT = { ROWS: 7, HEAD_TOP_ROW: 1, HEAD_BOT_ROW: 2, BOOK_TOP_ROW: 3, BOOK_BOT_ROW: 6, MSG_ROW: 7, SIDE_PAD: 0.06, // margin down each side, as a share of width TOP_PAD: 0.04, // clear space above the headline BOTTOM_PAD: 0.03, // clear space below the messages HEAD_FILL: 0.88, // headline fills this much of the width <- HEADLINE SIZE HEAD_BAND_FILL: 0.78, // ...and no more of the space above the book HEAD_CLEAR: 0.025, // clear gap between headline and top of book HEAD_Y: 0.50, // 0.5 = headline centred between screen top and book top HEAD_PREFER: 0.72, // use FEWER lines only if they give at least this // share of the biggest possible type size BOOK_X: 0.00, // nudge the book sideways, as a share of width // negative = left, e.g. -0.02 MSG_FILL: 0.58, // messages fill this much of the width <- MESSAGE SIZE MSG_POS: 0.62, // 0.5 = centred under the book, higher = lower MSG_MAX_PX: 42, // ...but never bigger than this STAND_ON_FLOOR: false }; /* Headline settings, best first. Every line has at least two words. */ var HEADLINE_SPLITS = [ ['THE ULTIMATE COLLECTION', 'OF VILLAIN QUOTES'], ['THE ULTIMATE', 'COLLECTION OF', 'VILLAIN QUOTES'] ]; var SECTIONS = [ { name: 'desktop', canvasId: 'bookCanvas', section: '.homepage-image-section', trigger: '.homepage-image-section', start: 'top top', endPortion: 0.8 } ]; if (!window.gsap || !window.ScrollTrigger) { console.warn('Book sequence: GSAP not found'); return; } gsap.registerPlugin(ScrollTrigger); var reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; function beside() { return window.innerWidth >= window.innerHeight; } function columns(w) { var gw = Math.min(w, GRID.MAX_WIDTH); var off = (w - gw) / 2; var c = gw / (GRID.COLS + GRID.GUTTER); var half = GRID.COLS / 2; function left(n) { return off + (n - 1) * c + (n > half ? GRID.GUTTER * c : 0); } return { c: c, left: left, right: function (n) { return left(n) + c; } }; } var frames = [], firstLoaded = [], firstIdx = reduced ? STILL_FRAME : 0; function loadFrames() { var queue = []; if (reduced) { queue.push(STILL_FRAME); } else { for (var n = 0; n < FRAME_COUNT; n++) queue.push(n); } var q = 0, inFlight = 0, MAX = 4; function pump() { while (inFlight < MAX && q < queue.length) { (function (idx) { var img = new Image(); inFlight++; img.onload = img.onerror = function () { inFlight--; if (idx === firstIdx) firstLoaded.forEach(function (fn) { fn(); }); pump(); }; img.src = FRAME_BASE + FRAME_NAME(idx); frames[idx] = img; })(queue[q++]); } } pump(); } function floorY(w, h) { var scale = Math.max(w / (BG_ASPECT * 1000), h / 1000); return h - (1 - BG_FLOOR) * 1000 * scale; } function rowY(h, n) { return h * n / PORT.ROWS; } /* Kept so the motion code can still find the text after the script has moved it out of the Webflow markup in portrait. */ var REFS = null; function setup(cfg) { var canvas = document.getElementById(cfg.canvasId); var section = document.querySelector(cfg.section); var trigger = document.querySelector(cfg.trigger); if (!canvas || !section || !trigger) return; if (section.offsetParent === null) return; var ctx = canvas.getContext('2d'); var state = { frame: reduced ? STILL_FRAME : 0 }; var textOffsetY = 0; var laidOutW = 0, laidOutH = 0; // canvas size the text was last placed against var headlineEl = section.querySelector('.splash-headline'); var splashText = headlineEl ? headlineEl.parentElement : null; var slotEl = null; if (splashText) { for (var i = 0; i < splashText.children.length; i++) { if (splashText.children[i] !== headlineEl) { slotEl = splashText.children[i]; break; } } } var msgEls = Array.prototype.slice.call(section.querySelectorAll(MESSAGE_SEL)); REFS = { section: section, headline: headlineEl, msgs: msgEls }; // Capture the size relationship your CSS already gives these two, // before the script starts setting sizes of its own. var msgRatio = TEXT.MSG_RATIO; if (!msgRatio && headlineEl && msgEls.length) { var h0 = parseFloat(window.getComputedStyle(headlineEl).fontSize); var m0 = parseFloat(window.getComputedStyle(msgEls[0]).fontSize); msgRatio = (h0 && m0) ? m0 / h0 : 0.34; } if (!msgRatio) msgRatio = 0.34; var curSplit = null; function applySplit(lines) { if (!headlineEl || curSplit === lines) return; /* Each line in its own span, so the real rendered width of the text can be measured - the element itself is a centred block and is always the full width of its container. */ headlineEl.innerHTML = lines.map(function (t) { return '' + t + ''; }).join('
'); curSplit = lines; } function renderedWidest() { if (!headlineEl) return 0; var spans = headlineEl.querySelectorAll('.eg-line'); var max = 0; for (var i = 0; i < spans.length; i++) { var wd = spans[i].getBoundingClientRect().width; if (wd > max) max = wd; } return max; } var ruler = null; if (headlineEl) { applySplit(HEADLINE_LINES); headlineEl.style.whiteSpace = 'normal'; ruler = document.createElement('span'); ruler.setAttribute('aria-hidden', 'true'); ruler.style.cssText = 'position:absolute;left:-99999px;top:0;visibility:hidden;white-space:nowrap;'; document.body.appendChild(ruler); } // Widest of a set of strings, in a given element's typeface, at a given size. function widestOf(el, lines, px) { if (!ruler || !el) return 0; var cs = window.getComputedStyle(el); ruler.style.fontFamily = cs.fontFamily; ruler.style.fontWeight = cs.fontWeight; ruler.style.fontStyle = cs.fontStyle; ruler.style.letterSpacing = cs.letterSpacing; ruler.style.textTransform = cs.textTransform; ruler.style.fontSize = px + 'px'; var max = 0; for (var i = 0; i < lines.length; i++) { ruler.textContent = lines[i]; var wd = ruler.getBoundingClientRect().width; if (wd > max) max = wd; } return max; } function msgTexts() { return msgEls.map(function (e) { return (e.textContent || '').trim(); }); } function linesIn(el, fallback) { var cs = window.getComputedStyle(el); var lh = parseFloat(cs.lineHeight); if (!lh || isNaN(lh)) lh = parseFloat(cs.fontSize) * 1.2; if (!lh) return fallback; return Math.round(el.getBoundingClientRect().height / lh); } /* Size the headline and the messages together so that - the headline sits on exactly three lines, and - no message ever wraps. Whichever of those two is the binding constraint wins. */ var fitBlockAt = function () {}; // set by fitBlock; re-applies a size function fitBlock(space, sizeMessages) { var texts = msgTexts(); var byHead = widestOf(headlineEl, HEADLINE_LINES, 100); var px = byHead ? space * 100 / byHead : TEXT.MIN_PX; if (sizeMessages && msgEls.length) { var byMsg = widestOf(msgEls[0], texts, 100); if (byMsg) px = Math.min(px, (space * 100 / byMsg) / msgRatio); } if (px < TEXT.MIN_PX) px = TEXT.MIN_PX; if (px > TEXT.MAX_PX) px = TEXT.MAX_PX; function apply(p) { sp(headlineEl, 'font-size', p + 'px'); if (sizeMessages) { /* Keep the designed proportion to the headline, but never let the messages fall below a readable size. On a narrow landscape phone the column is only ~250px, and the raw proportion would put them at 7px. */ var ms = p * msgRatio; if (ms < TEXT.MSG_MIN_PX) ms = TEXT.MSG_MIN_PX; for (var i = 0; i < msgEls.length; i++) { sp(msgEls[i], 'font-size', ms + 'px'); } } } function tooTall() { if (linesIn(headlineEl, HEADLINE_LINES.length) > HEADLINE_LINES.length) return true; if (sizeMessages) { for (var i = 0; i < msgEls.length; i++) { if (linesIn(msgEls[i], 1) > 1) return true; } } return false; } fitBlockAt = apply; apply(px); var guard = 0; while (tooTall() && px > TEXT.MIN_PX && guard++ < 60) { px = px * 0.95; if (px < TEXT.MIN_PX) px = TEXT.MIN_PX; apply(px); } return px; } /* Portrait headline. Try each way of breaking the line; take whichever gives the BIGGEST type that still fits both the width and the space above the book. Returns the size used. */ /* Work out what type size each way of breaking the line would give, then prefer FEWER lines - but only while fewer lines still get at least HEAD_PREFER of the best size available. On a wide screen two lines costs little, so two lines win. On a narrow phone two lines would be tiny, so it takes three. */ function portraitHeadline(maxWidth, maxHeight) { var opts = []; for (var i = 0; i < HEADLINE_SPLITS.length; i++) { var s = HEADLINE_SPLITS[i]; applySplit(s); sp(headlineEl, 'font-size', '100px'); var h100 = headlineEl.getBoundingClientRect().height; var w100 = widestOf(headlineEl, s, 100); if (!h100 || !w100) continue; opts.push({ split: s, size: Math.min(maxWidth * 100 / w100, maxHeight * 100 / h100, TEXT.MAX_PX) }); } if (!opts.length) { var last = HEADLINE_SPLITS[HEADLINE_SPLITS.length - 1]; applySplit(last); sp(headlineEl, 'font-size', TEXT.MIN_PX + 'px'); return TEXT.MIN_PX; } var best = 0; for (var j = 0; j < opts.length; j++) if (opts[j].size > best) best = opts[j].size; var pick = opts[0]; for (var k = 0; k < opts.length; k++) { pick = opts[k]; if (opts[k].size >= best * PORT.HEAD_PREFER) break; } applySplit(pick.split); var size = pick.size < TEXT.MIN_PX ? TEXT.MIN_PX : pick.size; sp(headlineEl, 'font-size', size + 'px'); /* The ruler only estimates. Now look at what actually rendered and correct, so the headline really does fill the width asked for. */ for (var pass = 0; pass < 4; pass++) { var rw = renderedWidest(); var rh = headlineEl.getBoundingClientRect().height; if (!rw || !rh) break; var scale = Math.min(maxWidth / rw, maxHeight / rh); if (scale > 0.99 && scale < 1.01) break; size = size * scale; if (size > TEXT.MAX_PX) size = TEXT.MAX_PX; if (size < TEXT.MIN_PX) size = TEXT.MIN_PX; sp(headlineEl, 'font-size', size + 'px'); } return size; } function fit() { var w = canvas.clientWidth, h = canvas.clientHeight; if (!w || !h) return false; var dpr = Math.min(window.devicePixelRatio || 1, 2); canvas.width = Math.round(w * dpr); canvas.height = Math.round(h * dpr); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); return true; } function refImage() { for (var i = 0; i < frames.length; i++) { var f = frames[i]; if (f && f.complete && f.naturalWidth) return f; } return null; } function geomFor(img) { var w = canvas.clientWidth, h = canvas.clientHeight; if (!w || !h || !img || !img.naturalWidth) return null; var aspect = img.naturalWidth / img.naturalHeight; var visW = 1 - INSET.left - INSET.right; // book's share of the file, across var visH = 1 - INSET.top - INSET.bottom; // ...and down if (visW <= 0.2 || visH <= 0.2) { visW = 1; visH = 1; } var imgW, imgH, bookW, bookH, cx; if (beside()) { var G = columns(w); var span = G.right(GRID.BOOK_TO) - G.left(GRID.BOOK_FROM); cx = G.left(GRID.BOOK_FROM) + span / 2; bookW = span; // the BOOK fills the columns imgW = bookW / visW; // so the file is drawn larger than that imgH = imgW / aspect; bookH = imgH * visH; } else { // Portrait: the book spans rows 3-6, cover top to spine base. cx = w / 2 + w * PORT.BOOK_X; bookH = rowY(h, PORT.BOOK_BOT_ROW) - rowY(h, PORT.BOOK_TOP_ROW - 1); imgH = bookH / visH; imgW = imgH * aspect; bookW = imgW * visW; var usableP = w * (1 - 2 * PORT.SIDE_PAD); if (bookW > usableP) { var k = usableP / bookW; imgW *= k; imgH *= k; bookW *= k; bookH *= k; } } var fy; if (beside()) { // Landscape: stand on the painted floor, never too tall, never too high. fy = floorY(w, h); var room = Math.min(h * GRID.BOOK_MAX_H, fy - h * GRID.TOP_CLEAR); if (room > 0 && bookH > room) { var s = room / bookH; imgW *= s; imgH *= s; bookW *= s; bookH *= s; } } else { fy = PORT.STAND_ON_FLOOR ? floorY(w, h) : rowY(h, PORT.BOOK_BOT_ROW); } var imgTop = fy - imgH * (1 - INSET.bottom); var imgLeft = cx - imgW * (INSET.left + visW / 2); return { beside: beside(), draw: { x: imgLeft, y: imgTop, w: imgW, h: imgH }, left: cx - bookW / 2, right: cx + bookW / 2, top: fy - bookH, bottom: fy, w: bookW, h: bookH }; } /* Webflow's own rules can carry !important inside a breakpoint's media query, which beats an ordinary inline style. These two write and clear properties AS important, so the script always wins. */ function sp(el, prop, val) { if (el) el.style.setProperty(prop, val, 'important'); } function clr(el, prop) { if (el) el.style.removeProperty(prop); } function bookGeom() { return geomFor(refImage()); } /* The portrait text is pinned to the screen, so it must be hidden whenever the book section is not the thing filling the screen - otherwise it floats over the sections above and below. */ /* The text is pinned to the canvas's exact position, so it travels with the book. It only needs hiding when the book section is not on screen AT ALL - any earlier and the message would wink out while the book is still visible. */ function canvasOnScreen() { var r = canvas.getBoundingClientRect(); var vh = window.innerHeight || document.documentElement.clientHeight; if (!vh || !r.height) return false; var overlap = Math.min(r.bottom, vh) - Math.max(r.top, 0); return overlap > 0; } /* ---------- THE PORTRAIT HOST ---------- In portrait the headline and the message block are MOVED out of the Webflow markup into a container of the script's own, attached straight to the page body. Nothing in Webflow's tree can then redefine what "fixed" means or where "top" is measured from. They are put back, untouched, when the window goes landscape. */ var portHost = null, moved = false, typeCaptured = false; function ensureHost() { if (portHost) return portHost; portHost = document.createElement('div'); portHost.className = 'eg-portrait-text'; document.body.appendChild(portHost); return portHost; } /* Copy the typography onto the elements themselves, so moving them out of their parent cannot change how they look. */ function captureType(el) { if (!el) return; var cs = window.getComputedStyle(el); var fs = parseFloat(cs.fontSize) || 16; var lh = parseFloat(cs.lineHeight); ['font-family', 'font-weight', 'font-style', 'letter-spacing', 'text-transform', 'color'].forEach(function (prop) { sp(el, prop, cs.getPropertyValue(prop)); }); if (lh && !isNaN(lh)) sp(el, 'line-height', (lh / fs).toFixed(3)); } function captureAll() { if (typeCaptured) return; captureType(headlineEl); for (var i = 0; i < msgEls.length; i++) captureType(msgEls[i]); typeCaptured = true; } function moveIn() { if (moved) return ensureHost(); captureAll(); var host = ensureHost(); host.appendChild(headlineEl); if (slotEl) host.appendChild(slotEl); moved = true; return host; } function moveOut() { if (!moved) return; splashText.insertBefore(headlineEl, splashText.firstChild); if (slotEl) splashText.insertBefore(slotEl, headlineEl.nextSibling); if (portHost) sp(portHost, 'display', 'none'); moved = false; } /* Undo EVERYTHING the portrait branch sets, so nothing leaks into landscape. Keep this list in step with the portrait code. */ var PORT_PROPS = { host: ['position','top','left','right','bottom','width','height', 'margin','padding','text-align','visibility','display'], headline: ['position','top','left','right','bottom','margin','margin-top', 'margin-bottom','padding','padding-top','padding-bottom', 'padding-left','padding-right','max-width','width','display', 'text-align','opacity','visibility'], slot: ['position','top','left','right','bottom','height', 'margin','margin-top','padding'], message: ['position','top','left','right','bottom','margin','text-align'] }; function clearStack() { PORT_PROPS.host.forEach(function (p) { clr(splashText, p); }); PORT_PROPS.headline.forEach(function (p) { clr(headlineEl, p); }); if (slotEl) PORT_PROPS.slot.forEach(function (p) { clr(slotEl, p); }); for (var ci = 0; ci < msgEls.length; ci++) { PORT_PROPS.message.forEach(function (p) { clr(msgEls[ci], p); }); } } function placeText() { if (!splashText || !headlineEl) return; /* Start every layout from a blank slate. moveOut() puts the text back in the Webflow markup when we are landscape; clearStack() strips every portrait property. Both run BEFORE any early exit, so a pass that cannot finish still cannot leave portrait styling stuck on. */ if (beside()) moveOut(); clearStack(); var g = bookGeom(); if (!g) return; var w = canvas.clientWidth, h = canvas.clientHeight; var cRect = canvas.getBoundingClientRect(); var op = splashText.offsetParent; var opRect = op ? op.getBoundingClientRect() : { left: 0, top: 0 }; var dx = cRect.left - opRect.left; var dy = cRect.top - opRect.top; textOffsetY = 0; if (g.beside) { sp(splashText, 'position', 'absolute'); sp(splashText, 'right', 'auto'); sp(splashText, 'max-width', 'none'); sp(splashText, 'padding-left', '0px'); sp(splashText, 'padding-right', '0px'); sp(headlineEl, 'padding-left', '0px'); sp(headlineEl, 'padding-right', '0px'); sp(headlineEl, 'max-width', 'none'); sp(headlineEl, 'width', 'auto'); applySplit(HEADLINE_LINES); var G = columns(w); var tLeft, tWidth; if (w < GRID.NARROW_W) { /* Narrow landscape (a phone on its side): the grid's three columns are far too tight - about 190px at 730 wide - so take everything from just right of the book to the margin. */ tLeft = G.right(GRID.BOOK_TO) + G.c * GRID.GUTTER; tWidth = (w - G.c * 0.5) - tLeft; } else { tLeft = G.left(GRID.TEXT_FROM); tWidth = G.right(GRID.TEXT_TO) - tLeft; // Never wider than the book, so the two stay in proportion. tWidth = Math.min(tWidth, g.w * TEXT.MAX_VS_BOOK); } sp(splashText, 'width', Math.round(tWidth) + 'px'); sp(splashText, 'left', Math.round(dx + tLeft) + 'px'); /* Column, width, type size - your Webflow layout does the rest. Plus padding under the headline, so the first message cannot ride up into it. */ var lSize = fitBlock(tWidth * TEXT.FILL, true); sp(headlineEl, 'padding-bottom', Math.round(lSize * HEADLINE_PAD) + 'px'); if (w < GRID.NARROW_W) { /* A landscape phone is only ~390px tall. Shrink the block if it does not fit, then centre it on the screen - on wider landscape windows Webflow's own vertical position is kept. */ sp(splashText, 'top', Math.round(dy) + 'px'); sp(splashText, 'bottom', 'auto'); var room = h * (1 - GRID.NARROW_PAD * 2); var guard = 0; while (splashText.getBoundingClientRect().height > room && lSize > TEXT.MIN_PX && guard++ < 40) { lSize = lSize * 0.94; if (lSize < TEXT.MIN_PX) lSize = TEXT.MIN_PX; fitBlockAt(lSize, true); sp(headlineEl, 'padding-bottom', Math.round(lSize * HEADLINE_PAD) + 'px'); } var bh = splashText.getBoundingClientRect().height; var ty = (h - bh) / 2; if (ty < h * GRID.NARROW_PAD) ty = h * GRID.NARROW_PAD; sp(splashText, 'top', Math.round(dy + ty) + 'px'); } laidOutW = w; laidOutH = h; return; } /* ---- PORTRAIT ---- Everything hangs off the script's own container, pinned to the canvas in screen coordinates and hidden whenever the book section is not the thing filling the screen. */ var host = moveIn(); var pad = w * PORT.SIDE_PAD; var usable = w - pad * 2; sp(host, 'position', 'fixed'); sp(host, 'display', 'block'); sp(host, 'left', Math.round(cRect.left + pad) + 'px'); sp(host, 'top', Math.round(cRect.top) + 'px'); sp(host, 'width', Math.round(usable) + 'px'); sp(host, 'height', Math.round(h) + 'px'); sp(host, 'right', 'auto'); sp(host, 'bottom', 'auto'); sp(host, 'margin', '0px'); sp(host, 'padding', '0px'); sp(host, 'text-align', 'center'); sp(host, 'pointer-events', 'none'); sp(host, 'z-index', '5'); sp(host, 'visibility', canvasOnScreen() ? 'visible' : 'hidden'); sp(headlineEl, 'position', 'absolute'); sp(headlineEl, 'left', '0px'); sp(headlineEl, 'right', '0px'); sp(headlineEl, 'bottom', 'auto'); sp(headlineEl, 'margin', '0px'); sp(headlineEl, 'padding', '0px'); sp(headlineEl, 'max-width', 'none'); sp(headlineEl, 'width', 'auto'); sp(headlineEl, 'display', 'block'); sp(headlineEl, 'text-align', 'center'); var headRoom = g.top - h * PORT.TOP_PAD; if (headRoom < h * 0.08) headRoom = h * 0.08; portraitHeadline(usable * PORT.HEAD_FILL, headRoom * PORT.HEAD_BAND_FILL); /* Put the headline where its MEASURED centre sits at HEAD_Y of the way from the top of the screen to the top of the book. Place it, look at where it actually landed, then shift by the difference - so leading, box height and font quirks cannot throw it off. */ var hb = headlineEl.getBoundingClientRect().height; var wantCentre = g.top * PORT.HEAD_Y; var headTop = wantCentre - hb / 2; sp(headlineEl, 'top', Math.round(headTop) + 'px'); var hostTop = host.getBoundingClientRect().top; var hr = headlineEl.getBoundingClientRect(); var gotCentre = (hr.top + hr.bottom) / 2 - hostTop; headTop += (wantCentre - gotCentre); var maxBottom = g.top - h * PORT.HEAD_CLEAR; if (headTop + hb > maxBottom) headTop = maxBottom - hb; if (headTop < h * PORT.TOP_PAD) headTop = h * PORT.TOP_PAD; sp(headlineEl, 'top', Math.round(headTop) + 'px'); if (msgEls.length) { var m100 = widestOf(msgEls[0], msgTexts(), 100); var msz = m100 ? (usable * PORT.MSG_FILL) * 100 / m100 : TEXT.MIN_PX; if (msz < TEXT.MIN_PX) msz = TEXT.MIN_PX; if (msz > PORT.MSG_MAX_PX) msz = PORT.MSG_MAX_PX; for (var mi = 0; mi < msgEls.length; mi++) { sp(msgEls[mi], 'font-size', msz + 'px'); } } if (slotEl) { sp(slotEl, 'position', 'absolute'); sp(slotEl, 'left', '0px'); sp(slotEl, 'right', '0px'); sp(slotEl, 'top', '0px'); sp(slotEl, 'bottom', 'auto'); sp(slotEl, 'margin', '0px'); sp(slotEl, 'padding', '0px'); sp(slotEl, 'height', Math.round(h) + 'px'); } var msgLow = h * (1 - PORT.BOTTOM_PAD); var msgMid = g.bottom + (msgLow - g.bottom) * PORT.MSG_POS; for (var mj = 0; mj < msgEls.length; mj++) { var me = msgEls[mj]; sp(me, 'position', 'absolute'); sp(me, 'left', '0px'); sp(me, 'right', '0px'); sp(me, 'bottom', 'auto'); sp(me, 'margin', '0px'); sp(me, 'text-align', 'center'); var mh = me.getBoundingClientRect().height; sp(me, 'top', Math.round(msgMid - mh / 2) + 'px'); } sp(headlineEl, 'opacity', '1'); sp(headlineEl, 'visibility', 'visible'); laidOutW = w; laidOutH = h; } function render() { var w = canvas.clientWidth, h = canvas.clientHeight; if (!w || !h) return; var idx = Math.round(state.frame); var img = frames[idx]; while (idx > 0 && (!img || !img.complete || !img.naturalWidth)) { idx--; img = frames[idx]; } if (!img || !img.complete || !img.naturalWidth) return; var g = geomFor(img); if (!g) return; ctx.clearRect(0, 0, w, h); ctx.drawImage(img, g.draw.x, g.draw.y, g.draw.w, g.draw.h); } firstLoaded.push(function () { fit(); render(); placeText(); }); // Measure the real transparent border once, then re-lay out. measureInset(FRAME_BASE + FRAME_NAME(1), function (m) { INSET = m; fit(); render(); placeText(); }); var settle; function onResize() { /* Rotation: undo portrait immediately, before anything that could bail out on a canvas that has not settled to its new size. */ if (beside()) { moveOut(); clearStack(); } fit(); render(); placeText(); clearTimeout(settle); settle = setTimeout(function () { buildTextMotion(); // orientation may have flipped ScrollTrigger.refresh(); placeText(); }, 200); } /* The canvas is sticky - it holds still while the section scrolls past. The text is not, so without this it drifts away from the book the moment you scroll. Re-pin it to the canvas on every scroll frame. */ var rafPending = false; function syncTextTop() { if (beside()) { // landscape: Webflow owns it if (portHost && !moved) sp(portHost, 'display', 'none'); return; } if (!portHost || !moved) return; var r = canvas.getBoundingClientRect(); if (!r.height) return; sp(portHost, 'top', Math.round(r.top) + 'px'); sp(portHost, 'left', Math.round(r.left + r.width * PORT.SIDE_PAD) + 'px'); sp(portHost, 'visibility', canvasOnScreen() ? 'visible' : 'hidden'); } /* The sticky canvas can change size after the first layout. If it has, place the text again - otherwise the positions stay frozen at whatever the canvas measured when the page first loaded. */ function relayoutIfStale() { /* Self-heal: if we are landscape but still carrying portrait styling, or the canvas has changed size, lay out again. */ if ((beside() && moved) || canvas.clientWidth !== laidOutW || canvas.clientHeight !== laidOutH) placeText(); } function onScroll() { if (rafPending) return; rafPending = true; requestAnimationFrame(function () { rafPending = false; relayoutIfStale(); syncTextTop(); }); } window.addEventListener('scroll', onScroll, { passive: true }); window.addEventListener('resize', onResize); window.addEventListener('orientationchange', onResize); /* Watch the canvas itself, not just the window - a sticky element can change size without the window ever resizing. */ if (window.ResizeObserver) { try { new ResizeObserver(function () { fit(); render(); placeText(); }).observe(canvas); } catch (e) {} } if (document.fonts && document.fonts.ready) { document.fonts.ready.then(function () { placeText(); }); } setTimeout(placeText, 400); // Self-check: says in the console why the headline is or is not visible. setTimeout(function () { if (!headlineEl) { console.log('[EG] headline element NOT FOUND'); return; } var cs = window.getComputedStyle(headlineEl); var r = headlineEl.getBoundingClientRect(); console.log('[EG] headline opacity ' + cs.opacity + ' visibility ' + cs.visibility + ' display ' + cs.display + ' top ' + Math.round(r.top) + ' height ' + Math.round(r.height) + ' size ' + cs.fontSize + ' colour ' + cs.color + ' z ' + cs.zIndex + ' w-id ' + headlineEl.getAttribute('data-w-id')); }, 2000); if (reduced) return; var st = { trigger: trigger, start: cfg.start, scrub: SCRUB }; if (cfg.end) st.end = cfg.end; else st.end = function () { return '+=' + (trigger.offsetHeight - window.innerHeight) * cfg.endPortion; }; var bookTween = gsap.to(state, { frame: FRAME_COUNT - 1, ease: 'none', onUpdate: render, scrollTrigger: st }); bookTween.scrollTrigger.__book = true; } SECTIONS.forEach(setup); if (document.readyState === 'complete') loadFrames(); else window.addEventListener('load', loadFrames); /* Where each message appears and disappears, as a share of the whole scroll through the section. The LAST one has hold:true, so it does not fade out - it stays on screen until the section itself ends. Raise END_HOLD toward 1.0 for a longer hold at the end. */ var END_HOLD = 1.00; var MESSAGE_BEATS = [ { sel: '.message1, .message-1', from: 0.10, to: 0.30 }, { sel: '.message2, .message-2', from: 0.32, to: 0.52 }, { sel: '.message3, .message-3', from: 0.54, to: 0.74 }, { sel: '.message4, .message-4', from: 0.76, to: END_HOLD, hold: true } ]; var FADE = 0.04; /* The script owns the text's opacity as well as its position, so that nothing else can move it. Rebuilt whenever the window changes shape. */ function buildTextMotion() { if (!REFS) return; var section = REFS.section; if (!section || section.offsetParent === null) return; function msgFor(sel) { for (var i = 0; i < REFS.msgs.length; i++) { var m = REFS.msgs[i]; if (m.matches && m.matches(sel)) return m; } return null; } // Clear whatever was built last time - never the book itself. ScrollTrigger.getAll().forEach(function (t) { if (t.trigger === section && !t.__book) t.kill(); }); var all = REFS.msgs.concat(REFS.headline ? [REFS.headline] : []); if (all.length) gsap.set(all, { clearProps: 'opacity,transform' }); if (REFS.headline) gsap.set(REFS.headline, { opacity: 1 }); // Landscape: all four on show at once, as the desktop layout has always been. if (beside() || reduced) { if (REFS.msgs.length) gsap.set(REFS.msgs, { opacity: 1 }); return; } // Portrait: one message at a time, so they take turns. var tl = gsap.timeline({ scrollTrigger: { trigger: section, start: 'top top', end: 'bottom bottom', scrub: SCRUB } }); MESSAGE_BEATS.forEach(function (b) { var el = msgFor(b.sel); if (!el) return; gsap.set(el, { opacity: 0, y: 12 }); var span = b.to - b.from; var keys; if (b.hold) { /* Last message: fade in, then stay. No fade-out, so it is still on screen when the section releases. */ keys = [ { opacity: 1, y: 0, duration: FADE, ease: 'none' }, { opacity: 1, y: 0, duration: span - FADE, ease: 'none' } ]; } else { keys = [ { opacity: 1, y: 0, duration: FADE, ease: 'none' }, { opacity: 1, y: 0, duration: span - FADE * 2, ease: 'none' }, { opacity: 0, y: -12, duration: FADE, ease: 'none' } ]; } tl.to(el, { keyframes: keys }, b.from); }); } if (document.readyState === 'complete') setTimeout(buildTextMotion, 50); else window.addEventListener('load', function () { setTimeout(buildTextMotion, 50); }); })();