A Chrome extension that translates whatever is under your pointer, text or image. Point at it, tap Ctrl, and it is translated in place. Tap it again to get the original back, Esc restores the whole page.
Replace, bilingual, bubble, then an image read and translated.
No account, no API key, no paid service. Three free providers, tried in the order you choose:
| Provider | Notes |
|---|---|
| Tencent TranSmart | A mainland China provider, and the strongest of the three on Chinese |
| Batches natively, fastest, widest language coverage | |
| MyMemory | Last resort: needs to be told the source language and caps each request at 500 characters |
If one fails the next is tried, so a single outage is not a dead extension. The popup shows which provider served the last block and how long it took, text and images counted separately.
Words in an image are pixels, so they have to be read before they can be translated. Pointing at an
image and tapping Ctrl reads it and lays the translation over it. The page's own DOM is
never touched: the result goes in a closed shadow root anchored above the image, so the <img> keeps
its original src and nothing is inserted beside it.
This is off until you turn it on, because reading an image the page did not serve itself is a cross-origin fetch that needs a host permission. Setting Translate images to Yes in the popup asks for that permission there and then; declining leaves the feature off rather than switching it on into something that cannot work. Setting it back to No hands the permission back.
| Recogniser | Notes |
|---|---|
| Youdao | Reads Chinese best by a distance, and translates as it reads. Returns a finished picture rather than boxes, so it replaces the image instead of annotating it |
| Google Lens | Any script. Returns each line with a box, which the usual providers above then translate |
| Yandex | Any script, and the only one that reports the text and background colours it found |
Youdao's endpoint ignores the target language: Chinese always comes back English, and everything else always comes back Chinese. So it declines outright unless the target is English or Chinese, and the language it actually answered in is checked before the result is used. When it does not match, the failover moves on to Lens.
- Open
chrome://extensionsand turn on Developer mode. - Load unpacked, and pick this folder.
- Open the popup to choose your target language, display mode and provider order.
| Action | Result |
|---|---|
| Hover text, tap Ctrl | Translates the block under the pointer |
| Hover an image, tap Ctrl | Reads the image and lays the translation over it |
| Hold Ctrl | Translates nothing, and outlines the block that would be, if you asked for that |
| Tap Ctrl on a translated block or image | Restores it |
| Esc | Restores every block and image on the page |
Nothing happens until you let go, so holding the key is how you aim and releasing it is how you commit; a press you think better of costs nothing. Shortcuts are unaffected: pressing any second key, clicking or scrolling while the trigger key is down cancels the press, so Ctrl+C, Ctrl+click and Ctrl+scroll all behave normally.
"Outline what would be translated" in the popup shows you what you are aiming at while the key is down. It is off by default, since the key is held for a moment before every translation and most of the time you already know what you are pointing at; turn it on while you are learning what a tap takes. It is the same block the trigger itself resolves, not a guess at it, so no outline means nothing would happen there. It is an outline rather than a border because an outline is painted outside the box and takes no space at all: the element keeps its size, nothing reflows, and no word moves under the pointer while you are aiming at it. On a double-tap trigger only the second press outlines anything, since the first one does nothing.
Each one is shown in the demo above, in order, before the image at the end.
| Mode | What it does |
|---|---|
| Replace | Rewrites the text where it stands. The default, and the only one that touches no nodes at all |
| Both | Keeps the original and adds the translation after it, as a single text node |
| Bubble | Leaves the page completely alone and shows the translation in an overlay, dismissed by moving the mouse |
Bilingual mode appends the translated text and nothing else, in a single <span>, so the original
is left exactly as it was.
A page sizes its text boxes for the language it shipped with, and the same sentence is usually longer
in another one, so part of the translation ends up where the page never meant to paint. It happens
two ways. Sideways: one nowrap line with its tail behind an ellipsis, which is what a sidebar menu
does. Downwards: the text wraps as it should and the box was simply given the height its own language
needed, so the second line exists, is laid out, and is painted nowhere, which is what a product card
does to a title. Neither cut is necessarily on the element holding the words, since a card sizes the
row rather than the title inside it, so the boxes above the text are checked too.
There are only two honest answers, and Scroll clipped lines in the popup picks between them.
| Setting | What happens to text that no longer fits |
|---|---|
| Enable | The default. It scrolls, unless the page left room around the box, which it takes instead |
| Disable | The box always grows |
There was a third setting that always scrolled and never took the room. It is gone, because next to nothing told the two apart: a menu row is built around its single line and has none to give, so it scrolls either way. They only parted company where a page left slack, and taking slack costs the page nothing. Anything still stored under the old name reads as the default.
A lap carries the line from off the right edge of the box to off the left one, at one steady 50px/s, for as long as you are pointing at the row. Because both ends of a lap are off screen the wrap has no seam, and because it never stops there is nothing for a reader to wait out. It holds still for a second first, so the beginning can be read where you were already looking, and it keeps the page's own ellipsis until it actually sets off.
Text cut downwards moves the same way, once the shape of the window says how. A window one line tall is a line, whichever way the page cut it, so the wrap comes out and it reads across exactly like the one above: the box was showing a single line before and it shows a single line now, so nothing about its height changes. A window several lines tall is a paragraph cut short instead, and undoing the wrap there would leave one line rattling around in a box built for three, so it rises through the window and comes back from below. That one is timed a line at a time, roughly one every two seconds, because reading down a paragraph is waiting for the next line rather than following words across.
Sliding is a transform on a wrapper span, the one node this extension adds to a page, and only
inside a box whose text it has already replaced. That is what makes it smooth: text-indent is a
layout property, so animating it relaid the line out on the main thread every frame, and anything
else busy there left the text standing still and then arriving late in one jump. A transform on its
own layer belongs to the compositor and page script cannot stall it. The wrapper is stripped when
the block is reverted, along with the animation.
It runs while the pointer is anywhere in the block rather than only on the box itself; a menu row is
130px wide and 24px tall, so anything smaller stops every time a hand drifts, and leaving is given
300ms of grace before the line is handed back. That is also why the movement is driven from the
content script rather than by a :hover rule: a CSS animation starts over every time its selector
matches again. Under prefers-reduced-motion nothing moves.
Growing wraps the line inside the width the page gave it, so the column stays where it is and only the height changes.
Only if no space means room the page already had, not room it can be made to give. A sidebar row is as tall as the one line in it, so a second line pushes every row below it down and the column reflows under the reader; that is not free, so those lines slide. A box with slack, a row taller than its text, grows into it. What that costs is measured rather than guessed: the box has to end up showing the whole line, the block it sits in has to end exactly where it ended before, and nothing above it may clip the new lines away. When any of that fails the line slides instead.
While a request is in flight a blue gradient sweeps along the bottom edge of the block. It is painted as a background, not a border, an outline or an appended element, so it adds no height, shifts nothing and inserts no node. Once the text is replaced nothing is left behind.
An image cannot use that trick, since a painted background sits behind the image's own pixels and would never be seen. It gets the same sweep drawn on top instead, full width across the middle of the image, on a track with a pale hairline so it stays legible over a light picture and a dark one alike.
- Every result is judged, not just the first. When a block's Chinese sits inside styled spans, the string carrying the sentence can be almost entirely brand names, and a provider reads that one as English. Judging the block on it alone made whole paragraphs vanish into "already in English". The majority of the detected languages now decides.
- The writing system overrules the detector. Han characters mean the text is not English whatever a detector claims, so a block is never skipped when it contains script the target language does not use. Latin is ignored as evidence, since brand names, model numbers and dates appear in every language. See src/lib/script.js.
The one that matters most installs the extension in a real headless Chrome and drives it with real mouse and keyboard events. It serves the folder, launches its own throwaway profile and cleans up:
node test/extension.test.mjsEvery provider against its live endpoint:
node test/remote.test.mjsPackage sanity, no browser needed:
node test/package.test.mjsTwo browser tests need the folder served over http, because ES module imports do not work from
file://:
python -m http.server 8731http://localhost:8731/test/harness.htmlrunsblock.js,richtext.js,marquee.jsandscript.jsagainst the fixture DOM.http://localhost:8731/test/e2e.htmlruns the whole content script over the fixture, translating through live endpoints, with only thechrome.*APIs stubbed.test/fixture.htmlis the manual page: open it with the extension loaded and hover through it.
- These are undocumented endpoints. They can rate-limit or change without notice, which is exactly why there are three of them and why failures surface in the popup rather than passing silently.
- The Lens recogniser is the least documented of the lot: its request shape was derived from the server's own error messages rather than a spec, so it is the first thing that will break if Google renumbers those fields. The failover means that degrades to Yandex rather than to nothing.
- A block over 5000 characters is refused, so a stray hover cannot rewrite half a page.
- An image over 12 MB is refused.
- Reloading or updating the extension leaves the copy already injected into open tabs with nothing behind it, and only a page load gets a fresh one. Rather than repeating Chrome's "Extension context invalidated" on every trigger, it says so once and asks you to reload the page. Undo still works, since restoring a block touches nothing but the DOM.

