Eye for Craft CMS

Usage

The library

Every embed lives in Eye → Embeds, as an element with a title, a URL, a handle and its options. Build one once and use it anywhere.

The library is also the audit trail. The index has a framing-status column, and switching an embed off takes it off every page that uses it at once — a disabled embed renders nothing, wherever it is referenced.

Reference tags

Each embed has a tag. The editor buttons write it for you; you can also type it:

{eye:promo-video:render}

A tag can carry per-use options in brackets. A bare word is a mode, a loading strategy, an alignment, a ratio or a flag; no- switches a flag off; anything else is key=value:

{eye:promo-video:render(click,height=600)}
{eye🗺render(full,no-showLoader)}
{eye:slides:render(4:3,width=640px)}

Craft's tag syntax allows no spaces, so a tag with one is left in the page as text. Longer text — a caption, consent wording — belongs on the library embed or in Twig.

Tags only change presentation. Anyone who can write rich text can write one, so a tag may set mode, ratio, height, minHeight, maxHeight, width, align, className, caption, title, loading, timeout, showLoader, showFallbackLink, consentTitle, consentText, consentButtonLabel, rememberConsent and scrollToTop — and nothing else. Other keys are ignored, and a tag cannot switch a framed embed to proxy or inline.

If a handle doesn't exist, Craft leaves the tag in the page as text, so a typo is visible rather than silently missing. Renaming an embed's handle breaks the tags that use the old one.

Paste a URL

Eye recognises 27 services and knows what each one needs — the real embed URL, the natural aspect ratio, the allow tokens, and the cookie-less host where there is one:

YouTube · Vimeo · Loom · Wistia · Dailymotion · TikTok · Instagram · X · Spotify · SoundCloud · Apple Podcasts · Apple Music · Google Maps · OpenStreetMap · Google Docs/Sheets/Slides/Forms · Google Calendar · Calendly · Typeform · Airtable · Figma · Miro · Canva · CodePen · JSFiddle · CodeSandbox · Descript · PDF

Anything else is framed as-is. Recognition is pattern matching on the URL — no oEmbed call, no request to the provider.

Pasting a URL creates a library embed, in the editor, in the picker, or on save with Auto-embed URLs on save turned on. That's deliberate: a reference tag needs an element, and it means every third-party frame on your site ends up in one index. Pastes are matched by URL, so pasting the same video ten times makes one embed.

To see what Eye makes of a URL without saving anything:

php craft eye/embeds/inspect https://youtu.be/dQw4w9WgXcQ

The five modes

An embed's mode decides how the frame gets its height, which is the whole problem.

ModeWhat it doesHeight comes from
Aspect ratioA responsive box that keeps its shape. The default.CSS aspect-ratio
FixedOne explicit height, in pixels.you
AutoThe frame reports its own height.a same-origin DOM read, or the child script
ProxyEye fetches the page server-side and serves it from your domain.a same-origin DOM read, or the child script Eye injects
InlineEye fetches the page and puts the content straight into yours. No iframe.it's just content

Ratio takes anything w:h — 16:9, 4:3, 16x9 and 1.7778 all work. Auto and proxy frames start at minHeight (240px) and grow; set maxHeight in a tag or in Twig to make a tall page scroll inside the frame instead. Measure this element instead measures one selector rather than the whole document, where the parent can read the DOM.

Proxy and inline are off until an admin turns them on. See Proxy & inline modes.

Auto height on sites you control

A browser will not let your page measure a frame from another origin. If you own both ends, include Eye's child script on the framed page, then set the embed's mode to Auto:

<script src="https://your-site.example/eye/child.js" defer></script>

It's about 1 KB, reads nothing, stores nothing, and sends nothing but a number. The URL is stable across deploys, so it is safe to paste into another site's templates.

Loading and consent

  • Lazy (default) — native loading="lazy", prepared 200px before it scrolls into view.
  • Eager — immediately.
  • On click — nothing is requested until the reader asks.

On click renders a consent card: the provider's name, a line of text, a poster image where there is one (YouTube), and a button. The heading, text and button label are all editable. The iframe sits inside a <template> element, so it genuinely has not been requested — a hidden iframe still loads, which is the mistake most consent banners make. There is a <noscript> link too.

Remember the reader's choice stores consent in localStorage per embed host, so one click loads every later embed from the same place. For a "privacy settings" link:

Eye.forgetConsent();

Privacy mode (on by default) uses a provider's cookie-less option where it has one — youtube-nocookie.com, Vimeo's dnt=1.

Knowing when an embed won't work

A page that refuses to be framed doesn't tell the framing page. The browser blocks it, logs a line nobody reads, and leaves a blank rectangle.

So Eye reads the target's X-Frame-Options and Content-Security-Policy: frame-ancestors when you save, and the index shows the result: Embeddable, Refuses framing, Not from this site (it allows other origins, not yours), Unreachable or Not checked. It probes the URL that actually goes in the frame, not the one you pasted.

For CI, or a weekly cron:

php craft eye/embeds/check --failOnProblem

Give up after (8 seconds by default) shows the fallback when a frame never finishes loading. It catches slow and dead frames, not blocked ones — see Troubleshooting.

Fields

  • Embed — paste a URL and configure it on the entry. In the field settings you choose which options authors see: mode, aspect ratio, height, loading, caption, accessible title, alignment, consent text, extract selector. A video field can be a URL box and nothing else. Options you didn't enable can't be posted either; they keep the field's defaults. Authors need no access to the library.
  • Embeds — an ordinary relation field pointing at the library, with eager loading and everything else relation fields do.
{{ entry.video }}                               {# renders #}
{{ entry.video.url }}
{{ entry.video.render({ loading: 'click' }) }}

{% for embed in entry.relatedEmbeds.all() %}
    {{ embed.render() }}
{% endfor %}

Twig

{# From the library, by handle or id #}
{{ craft.eye.render('promo-video') }}
{{ craft.eye.render('promo-video', { loading: 'click', ratio: '4:3' }) }}

{# Any URL, with everything Eye knows applied #}
{{ craft.eye.url('https://youtu.be/dQw4w9WgXcQ') }}

{# The filter and function take a URL, a handle, an element or a field value #}
{{ 'promo-video'|eye }}
{{ entry.video|eye({ align: 'full' }) }}
{{ eye('https://vimeo.com/76979871') }}

{# The element itself #}
{% set embed = craft.eye.embed('promo-video') %}
{{ embed.title }} — {{ embed.providerName }} — {{ embed.embedUrl }}

{# A normal element query #}
{% for embed in craft.eye.all({ provider: 'youtube' }).all() %}…{% endfor %}

{# Inspect without rendering #}
{% set match = craft.eye.match('https://youtu.be/x') %}
{% set framing = craft.eye.framability('https://example.com') %}
{{ craft.eye.embedCode('promo-video') }}        {# the reference tag #}
{{ craft.eye.childScriptUrl() }}

Options passed in Twig can be any of the embed options, not just the presentational ones a reference tag allows. An unknown handle renders nothing rather than an error, and only http and https URLs render at all.

craft.eye.framability() is a network call. It is cached, but don't put it on a hot page.

Overriding the markup

Put your own template at templates/_eye/embed.twig and it wins, with the same variables Eye's own template gets — options, src, classes, consent, fallback, provider, embed and the rest. Copy vendor/justinholtweb/craft-eye/src/templates/_render/embed.twig as a starting point; keep the <template data-eye-template> around the click-to-load iframe, or the consent card stops deferring anything.

The wrapper carries .eye plus .eye--{mode}, .eye--{loading}, .eye--align-{align} and .eye--provider-{handle}. The other hooks are .eye-stage, .eye-consent, .eye-fallback and .eye-caption. The colours are custom properties:

.eye { --eye-accent: #b91c1c; --eye-radius: 0; --eye-bg: #111; }

Also available: --eye-fg, --eye-muted, --eye-accent-fg, --eye-border. Turn off Register stylesheet to style embeds entirely from your own CSS.

The front-end runtime

Eye's script is registered the first time an embed renders, and not at all on a page with none. It picks up embeds added later — live preview, an AJAX tab, infinite scroll — on its own. By hand:

Eye.init(container);   // initialise embeds under an element
Eye.load(el);          // load a click-to-load embed
Eye.refresh(el);       // re-measure an auto-height embed
Eye.forgetConsent();

el is the .eye wrapper.