An interactive AI image-detection report rendered as a single-file web component — framework-agnostic, zero configuration.
Drop <copyleaks-visual-report> into any page and get a fully interactive viewer: a zoomable / pannable scanned image with AI-detection overlay masks, an opacity slider, multiple mask views, and a detailed results side-panel — all from one script tag.
Works natively in Angular, React, Vue 3, and plain HTML.
- Two usage modes — pass data in manually, or hand the component a set of API endpoints and let it fetch and poll automatically
- AI overlay masks — standard binary highlight (green = human, red = AI) and a gradual intensity heatmap
- Zoom & pan — mouse wheel, pinch-to-zoom, drag-to-pan, and zoom buttons (1×–4×); cursor-aware zoom keeps the hovered image point fixed
- Mask opacity control — slide the opacity of the overlay via the header popover
- View-type switcher — Standard AI View, AI Intensity Map, or Hide Mask
- Auto-polling — when given a
progressUrl, the component polls every 2 seconds until the scan is complete, then fetches results - Custom request headers — pass
Authorizationor any other header for authenticated endpoints - Loading skeletons — every data row in the results panel shows a skeleton while data is in flight
- Mobile swipe gesture — drag up/down on the results panel to collapse or expand it
- Content credentials — displays embedded image metadata (issuer, issued time, device, content summary) when present
- i18n ready — all user-visible strings use
$localizetagged templates; swap the translation file to localise the UI - Zoneless Angular — built with
provideExperimentalZonelessChangeDetectionfor maximum performance
npm install @copyleaks/visual-report
# or
yarn add @copyleaks/visual-report
# or
pnpm add @copyleaks/visual-reportThe package ships a single pre-built JS file. It registers the <copyleaks-visual-report> custom element as soon as the script loads — no build step required.
1. Serve the JS file — add an asset entry to your app's angular.json build options:
"assets": [
{
"glob": "copyleaks-visual-report.js",
"input": "node_modules/@copyleaks/visual-report",
"output": "/"
}
]2. Load it dynamically — inject the script from main.ts instead of a static <script> tag in index.html. Angular 17+ uses Vite as its dev server, which tries to pre-transform every type="module" script listed in index.html; because this file is a pre-built bundle (not a source file), Vite fails to resolve it. Injecting the tag at runtime bypasses that step entirely:
// main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { AppComponent } from './app/app.component';
const script = document.createElement('script');
script.type = 'module';
script.src = 'copyleaks-visual-report.js';
document.head.appendChild(script);
bootstrapApplication(AppComponent, appConfig).catch(console.error);3. Allow unknown elements — add CUSTOM_ELEMENTS_SCHEMA to any component that uses it.
4. Set properties after the element is defined — the web component bootstraps an Angular application asynchronously (createApplication is async), so customElements.define runs after several microtasks. Angular's property bindings fire on the first render cycle, before the element is registered, and pre-upgrade property queuing does not work reliably with signal-based inputs. Wait for customElements.whenDefined and then set properties via a template ref.
import {
CUSTOM_ELEMENTS_SCHEMA,
AfterViewInit,
Component,
ElementRef,
ViewChild,
} from '@angular/core';
@Component({
standalone: true,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
template: `
<copyleaks-visual-report
#reportEl
style="display: flex; height: 100vh;">
</copyleaks-visual-report>
`,
})
export class MyComponent implements AfterViewInit {
@ViewChild('reportEl') private reportElRef!: ElementRef<HTMLElement>;
private imageUrl = 'https://example.com/image.jpg';
private reportData: ImageDetectionResponseResult = { /* ... */ };
ngAfterViewInit(): void {
customElements.whenDefined('copyleaks-visual-report').then(() => {
const el = this.reportElRef.nativeElement as any;
el.isLoading = false;
el.imageUrl = this.imageUrl;
el.reportData = this.reportData;
el.addEventListener('viewTypeChange', (e: CustomEvent) => console.log('View type:', e.detail));
el.addEventListener('onReportRequestError', (e: CustomEvent) => console.error('Error:', e.detail));
el.addEventListener('loadingChange', (e: CustomEvent) => console.log('Loading:', e.detail));
});
}
}Pass a reportEndpointConfig object instead. The component handles all polling and fetching internally — do not set isLoading, imageUrl, or reportData alongside it.
import {
CUSTOM_ELEMENTS_SCHEMA,
AfterViewInit,
Component,
ElementRef,
ViewChild,
} from '@angular/core';
@Component({
standalone: true,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
template: `
<copyleaks-visual-report
#reportEl
style="display: flex; height: 100vh;">
</copyleaks-visual-report>
`,
})
export class MyComponent implements AfterViewInit {
@ViewChild('reportEl') private reportElRef!: ElementRef<HTMLElement>;
ngAfterViewInit(): void {
customElements.whenDefined('copyleaks-visual-report').then(() => {
const el = this.reportElRef.nativeElement as any;
el.reportEndpointConfig = {
resultsUrl: 'https://api.example.com/scan/123/results',
progressUrl: 'https://api.example.com/scan/123/progress',
imageUrlEndpoint: 'https://api.example.com/scan/123/image-url',
headers: { Authorization: 'Bearer <token>' },
};
el.addEventListener('onReportRequestError', (e: CustomEvent) => console.error('Error:', e.detail));
el.addEventListener('loadingChange', (e: CustomEvent) => console.log('Loading:', e.detail));
});
}
}1. Copy the JS file to public/ — add a postinstall script to your package.json so it is copied automatically after every install:
"scripts": {
"postinstall": "node -e \"const{cpSync}=require('fs');cpSync('node_modules/@copyleaks/visual-report/copyleaks-visual-report.js','public/copyleaks-visual-report.js');\""
}2. Load it dynamically — inject the script from main.tsx instead of a static <script> tag in index.html. Vite pre-transforms every type="module" script listed in index.html; because this file is a pre-built bundle it fails to resolve. Injecting the tag at runtime bypasses that step entirely:
// main.tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.tsx'
const script = document.createElement('script')
script.type = 'module'
script.src = '/copyleaks-visual-report.js'
document.head.appendChild(script)
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)3. Declare the custom element for TypeScript — add a declaration file so JSX accepts the <copyleaks-visual-report> tag:
// src/custom-elements.d.ts
import 'react'
declare module 'react' {
namespace JSX {
interface IntrinsicElements {
'copyleaks-visual-report': React.HTMLAttributes<HTMLElement> & {
ref?: React.RefObject<HTMLElement | null>
}
}
}
}4. Create a typed React wrapper — React sets HTML attributes rather than DOM properties, so complex objects (like reportData) must be forwarded through a ref. The wrapper also waits for customElements.whenDefined before setting any properties, since the web component bootstraps asynchronously:
// src/CopyleaksVisualReport.tsx
import { useRef, useEffect, useState, CSSProperties } from 'react'
interface ImageDetectionResponseResult {
/* see Data Models section below */
}
interface ReportEndpointConfig {
imageUrl?: string
imageUrlEndpoint?: string
resultsUrl: string
progressUrl?: string
headers?: Record<string, string>
}
interface Props {
imageUrl?: string
isLoading?: boolean
reportData?: ImageDetectionResponseResult | null
reportEndpointConfig?: ReportEndpointConfig
onViewTypeChange?: (type: string) => void
onReportRequestError?: (err: { statusCode: number; error?: { code?: number; message?: string } }) => void
onLoadingChange?: (loading: boolean) => void
style?: CSSProperties
className?: string
}
export function CopyleaksVisualReport({
imageUrl,
isLoading = true,
reportData,
reportEndpointConfig,
onViewTypeChange,
onReportRequestError,
onLoadingChange,
style,
className,
}: Props) {
const ref = useRef<HTMLElement>(null)
const [ready, setReady] = useState(false)
useEffect(() => {
customElements.whenDefined('copyleaks-visual-report').then(() => setReady(true))
}, [])
useEffect(() => {
if (!ready || !ref.current) return
const el = ref.current as any
el.isLoading = isLoading
el.imageUrl = imageUrl ?? null
el.reportData = reportData ?? null
if (reportEndpointConfig !== undefined) el.reportEndpointConfig = reportEndpointConfig
}, [ready, imageUrl, isLoading, reportData, reportEndpointConfig])
useEffect(() => {
const el = ref.current
if (!el || !ready) return
const handleViewType = (e: Event) => onViewTypeChange?.((e as CustomEvent).detail)
const handleError = (e: Event) => onReportRequestError?.((e as CustomEvent).detail)
const handleLoading = (e: Event) => onLoadingChange?.((e as CustomEvent).detail)
el.addEventListener('viewTypeChange', handleViewType)
el.addEventListener('onReportRequestError', handleError)
el.addEventListener('loadingChange', handleLoading)
return () => {
el.removeEventListener('viewTypeChange', handleViewType)
el.removeEventListener('onReportRequestError', handleError)
el.removeEventListener('loadingChange', handleLoading)
}
}, [ready, onViewTypeChange, onReportRequestError, onLoadingChange])
return <copyleaks-visual-report ref={ref} style={style} className={className} />
}5. Use it:
<CopyleaksVisualReport
isLoading={false}
imageUrl="https://example.com/image.jpg"
reportData={scanResult}
onViewTypeChange={(type) => console.log(type)}
style={{ display: 'flex', height: '100vh' }}
/><CopyleaksVisualReport
reportEndpointConfig={{
resultsUrl: 'https://api.example.com/scan/123/results',
progressUrl: 'https://api.example.com/scan/123/progress',
imageUrlEndpoint: 'https://api.example.com/scan/123/image-url',
headers: { Authorization: 'Bearer <token>' },
}}
onReportRequestError={(err) => console.error(err)}
style={{ display: 'flex', height: '100vh' }}
/>1. Copy the JS file to public/:
cp node_modules/@copyleaks/visual-report/copyleaks-visual-report.js public/Or add a postinstall script (same pattern as the React section above).
2. Load it dynamically — inject the script from main.ts instead of a static <script> tag in index.html. Vite pre-transforms every type="module" script listed in index.html; because this file is a pre-built bundle it fails to resolve. Injecting the tag at runtime bypasses that step entirely:
// main.ts
import { createApp } from 'vue'
import App from './App.vue'
const script = document.createElement('script')
script.type = 'module'
script.src = '/copyleaks-visual-report.js'
document.head.appendChild(script)
createApp(App).mount('#app')3. Tell Vue to treat the element as a custom element — in vite.config.ts:
// vite.config.ts
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [
vue({
template: {
compilerOptions: {
// Treat any tag starting with "copyleaks-" as a custom element
isCustomElement: (tag) => tag.startsWith('copyleaks-'),
},
},
}),
],
});4. Use it in a component — Vue does not pass object props to custom element DOM properties automatically, so all data must be forwarded through a ref. Wait for customElements.whenDefined before setting properties, then keep them in sync with watch.
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
const reportEl = ref<HTMLElement | null>(null)
const reportData = ref<ImageDetectionResponseResult>({ /* ... */ })
const imageUrl = ref('https://example.com/image.jpg')
const isLoading = ref(false)
function setProps() {
const el = reportEl.value as any
if (!el) return
el.isLoading = isLoading.value
el.imageUrl = imageUrl.value
el.reportData = reportData.value
}
onMounted(() => {
customElements.whenDefined('copyleaks-visual-report').then(() => setProps())
})
watch([isLoading, imageUrl, reportData], setProps)
</script>
<template>
<copyleaks-visual-report
ref="reportEl"
@viewTypeChange="(e: CustomEvent) => console.log('View type:', e.detail)"
@onReportRequestError="(e: CustomEvent) => console.error('Error:', e.detail)"
@loadingChange="(e: CustomEvent) => console.log('Loading:', e.detail)"
style="display: flex; height: 100vh;"
/>
</template><script setup lang="ts">
import { ref, onMounted } from 'vue'
const reportEl = ref<HTMLElement | null>(null)
onMounted(() => {
customElements.whenDefined('copyleaks-visual-report').then(() => {
const el = reportEl.value as any
if (!el) return
el.reportEndpointConfig = {
resultsUrl: 'https://api.example.com/scan/123/results',
progressUrl: 'https://api.example.com/scan/123/progress',
imageUrlEndpoint: 'https://api.example.com/scan/123/image-url',
headers: { Authorization: 'Bearer <token>' },
}
})
})
</script>
<template>
<copyleaks-visual-report
ref="reportEl"
@onReportRequestError="(e: CustomEvent) => console.error('Error:', e.detail)"
@loadingChange="(e: CustomEvent) => console.log('Loading:', e.detail)"
style="display: flex; height: 100vh;"
/>
</template><!DOCTYPE html>
<html lang="en">
<head>
<script src="copyleaks-visual-report.js" type="module"></script>
</head>
<body>
<copyleaks-visual-report id="report" style="display:flex;height:100vh;"></copyleaks-visual-report>
<script>
const report = document.getElementById('report');
// Properties must be set after the element is defined
customElements.whenDefined('copyleaks-visual-report').then(() => {
report.imageUrl = 'https://example.com/image.jpg';
report.isLoading = false;
report.reportData = { /* ImageDetectionResponseResult — see Data Models */ };
// Listen for events
report.addEventListener('viewTypeChange', (e) => console.log('View type:', e.detail));
report.addEventListener('onReportRequestError', (e) => console.error('Error:', e.detail));
report.addEventListener('loadingChange', (e) => console.log('Loading:', e.detail));
});
</script>
</body>
</html><copyleaks-visual-report> is a client-only custom element and cannot be rendered on the server.
It depends on browser-only APIs that have no server equivalent: the customElements registry, ResizeObserver, <canvas>, mouse and touch gesture handlers, and an Angular Elements bootstrap that calls createApplication against a real DOM. Even if a framework's renderer accepted the tag, the SSR'd HTML would be a non-interactive shell that fully re-hydrates on the client — there is no SEO or first-paint benefit to gain.
You can still use it inside an SSR'd app — just gate the render so it only happens in the browser. Per-framework patterns:
// app/report/page.tsx (App Router)
'use client'
import dynamic from 'next/dynamic'
const Report = dynamic(() => import('@/components/CopyleaksVisualReport'), {
ssr: false,
})
export default function Page() {
return <Report imageUrl="..." reportData={...} />
}The wrapper component itself (the typed React wrapper from the React section above) lives in a separate file and imports @copyleaks/visual-report. dynamic({ ssr: false }) ensures Next never tries to render it during SSR or static export.
<template>
<client-only>
<copyleaks-visual-report ref="reportEl" />
</client-only>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
const reportEl = ref<HTMLElement | null>(null)
onMounted(async () => {
await import('@copyleaks/visual-report')
await customElements.whenDefined('copyleaks-visual-report')
;(reportEl.value as any).reportData = { /* ... */ }
})
</script><client-only> prevents server rendering; the dynamic import() inside onMounted ensures the bundle never loads in the Nitro server bundle. Don't forget the Vite isCustomElement config from the Vue section above.
<script lang="ts">
import { browser } from '$app/environment'
import { onMount } from 'svelte'
let el: HTMLElement
onMount(async () => {
await import('@copyleaks/visual-report')
await customElements.whenDefined('copyleaks-visual-report')
;(el as any).reportData = { /* ... */ }
})
</script>
{#if browser}
<copyleaks-visual-report bind:this={el}></copyleaks-visual-report>
{/if}import { Component, PLATFORM_ID, inject } from '@angular/core'
import { isPlatformBrowser } from '@angular/common'
@Component({
standalone: true,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
template: `
@if (isBrowser) {
<copyleaks-visual-report #reportEl></copyleaks-visual-report>
}
`,
})
export class ReportComponent {
protected isBrowser = isPlatformBrowser(inject(PLATFORM_ID))
}Pair this with the dynamic-script-injection pattern from the Angular section above so the bundle is only fetched in the browser.
Mark the island as client:only:
---
import Report from '../components/CopyleaksVisualReport.tsx'
---
<Report client:only="react" imageUrl="..." reportData={...} />All inputs work in both the web component (<copyleaks-visual-report>) and the Angular library (<cls-visual-report>).
| Property | Type | Default | Description |
|---|---|---|---|
isLoading |
boolean |
true |
Show skeleton loaders while data is in flight |
imageUrl |
string | null |
null |
URL of the scanned image |
reportData |
ImageDetectionResponseResult | null |
null |
Scan result object returned by the Copyleaks API |
displayName |
string | null |
null |
Optional display name shown in the report |
<!-- Loading skeleton -->
<copyleaks-visual-report [isLoading]="true"></copyleaks-visual-report>
<!-- Data ready -->
<copyleaks-visual-report
[isLoading]="false"
[imageUrl]="imageUrl"
[reportData]="reportData">
</copyleaks-visual-report>When reportEndpointConfig is set, the component manages all HTTP requests internally. The manual isLoading, imageUrl, and reportData inputs are ignored.
| Property | Type | Description |
|---|---|---|
reportEndpointConfig |
IVisualReportEndpointConfig |
Endpoint URLs (and optional headers) the component uses to load the report |
| Property | Type | Default | Description |
|---|---|---|---|
injectFonts |
boolean |
true |
Auto-inject the Lato + Material Symbols Google Font stylesheets into <head>. Set to false to bring your own font setup |
The component depends on the Lato and Material Symbols Rounded fonts. By default it injects the Google Fonts stylesheets the first time it renders (de-duplicated across instances and frameworks). If your host page already loads these fonts, blocks third-party requests via CSP, or wants to self-host them, disable auto-injection:
<!-- HTML / property-set -->
<copyleaks-visual-report inject-fonts="false"></copyleaks-visual-report>// Angular / property-set via ref
el.injectFonts = false;When auto-injection is off, you must provide both font families yourself. Otherwise icons render as text and headings fall back to a system font.
const config: IVisualReportEndpointConfig = {
imageUrl: 'https://api.example.com/scan/123/image', // optional: direct image URL
imageUrlEndpoint: 'https://api.example.com/scan/123/image-url', // optional: endpoint returning { url: string }
resultsUrl: 'https://api.example.com/scan/123/results', // required
progressUrl: 'https://api.example.com/scan/123/progress', // optional: polls until percents === 100
headers: { Authorization: 'Bearer <token>' }, // optional: applied to all requests
};When progressUrl is provided, the component:
- Polls
progressUrlevery 2 seconds untilpercentsreaches100 - Fetches
resultsUrlto get the detection result - Sets
imageUrlfromimageUrlEndpointresponse if provided, otherwise uses the staticimageUrl
| Event name | event.detail type |
Emitted when |
|---|---|---|
viewTypeChange |
ViewType |
The user switches the overlay view type (standard / gradual / hidden) |
onReportRequestError |
IVisualReportRequestError |
An HTTP request made by the component fails |
loadingChange |
boolean |
The internal loading state changes (e.g. fetch starts or completes) |
Listening in vanilla JS / React / Vue:
report.addEventListener('viewTypeChange', (e) => {
// e.detail is 'standard' | 'gradual' | 'hidden'
console.log(e.detail);
});
report.addEventListener('onReportRequestError', (e) => {
// e.detail: { statusCode: number; error?: { code?: number; message?: string } }
console.error(e.detail);
});
report.addEventListener('loadingChange', (e) => {
// e.detail: boolean
console.log('Loading:', e.detail);
});The component renders with ViewEncapsulation.None at its root, so you can target it directly with standard CSS. There are no CSS custom properties (--var) exposed at this time — size the component via its host element:
copyleaks-visual-report {
display: flex;
width: 100%;
height: 600px; /* or flex: 1; inside a flex parent */
}The object you pass to reportData (or that the component receives from resultsUrl):
interface ImageDetectionResponseResult {
model: string; // Detection model name, e.g. "Ultra"
result: {
starts: number[]; // RLE span start positions
lengths: number[]; // RLE span lengths
labels?: number[]; // Per-span label (gradual mask mode)
};
summary: {
human: number; // 0.0–1.0 fraction of human pixels
ai: number; // 0.0–1.0 fraction of AI pixels
};
imageInfo: {
shape: {
height: number;
width: number;
size: string; // e.g. "2.4 MB"
};
metadata?: {
issuedTime?: string; // ISO 8601 date string
issuedBy?: string;
appOrDeviceUsed?: string;
contentSummary?: string;
};
};
scannedDocument: {
documentName: string;
scannedTime: string; // ISO 8601 date string
};
error?: {
type: string;
id: string;
code: number;
message: string;
};
}interface IVisualReportEndpointConfig {
imageUrl?: string; // Direct URL to the scanned image
imageUrlEndpoint?: string; // Endpoint returning { url: string } for the image
resultsUrl: string; // Endpoint returning ImageDetectionResponseResult JSON
progressUrl?: string; // Polling endpoint returning { percents: number; status: string }
headers?: Record<string, string>; // Custom request headers (e.g. Authorization)
}Payload of the onReportRequestError event:
interface IVisualReportRequestError {
statusCode: number; // HTTP status code (0 = network error)
error?: {
code?: number;
message?: string;
};
}Payload of the viewTypeChange event:
type ViewType = 'standard' | 'gradual' | 'hidden';| Value | Description |
|---|---|
standard |
Binary mask — green for human pixels, red for AI |
gradual |
Heatmap — orange gradient showing AI intensity |
hidden |
Original image with no overlay |
pnpm install
# Watch-build the library and serve the sandbox at localhost:4300
pnpm dev
# Build the Angular library only (dist/ngx-copyleaks-visual-report/)
pnpm build:lib
# Build the production web component bundle (single JS file)
pnpm build:wc
# Build the dev (unminified) web component bundle
pnpm build:wc:dev
# Watch-build the library only (no sandbox server)
pnpm build:watchOutput files:
| Path | Contents |
|---|---|
dist/ngx-copyleaks-visual-report/ |
Angular library (ng-packagr output) |
dist/copyleaks-visual-report-wc/browser/copyleaks-visual-report.js |
Web component bundle (publish this) |
MIT © Copyleaks