WebProCMS supports three ways of giving any page section a distinctive background: built-in static texture overlays, editor-uploaded looping videos, and animated <canvas> presets. Only the latter two actually move — the texture tier is a static CSS/SVG pattern layer, the free always-on default. All three are applied through the same row paintbrush ("Design") panel on any section in any row — no new row template required.
When to use it
A motion background is a strong but expensive design choice. Use it for:
- Hero sections that need to communicate energy or activity (SaaS launches, product reveals, conference pages, agencies)
- Brand statements where a static color or static image would feel flat
- Backgrounds-only loops — short, atmospheric video that adds depth without competing for the visitor's attention
Don't use it for:
- Content-dense rows where motion competes with the message
- Sections containing dense text the visitor needs to read carefully
- Visitors on flaky / metered connections (heavy hero videos eat data); the static texture overlays have no bandwidth cost and are a safer default
Texture overlays are static — there's no motion to reduce, so they render identically regardless of the visitor's OS motion preference. The animated canvas presets (v3 below) do respect prefers-reduced-motion: the first frame paints, then the loop stops. Video backgrounds autoplay muted with playsinline so they don't block scroll on mobile (video isn't gated by prefers-reduced-motion).
What's included
There are three tiers, all selected from the row paintbrush panel:
v1: Static texture overlays (no upload required, on by default)
Twenty-two named patterns ship with the CMS as a static -z-10 decoration layer painted behind the section content — no motion, no brand colour tokens, no per-row colour configuration. Selected via Section::textureOptions() in app/Support/DlSchemas/Section.php:
grid, graph-paper, dots, diagonal-lines, crosshatch, isometric, diagonal-hatch, blueprint, plus-marks, concentric, hex-mesh, triangle-mesh, brick, herringbone, chevron, fish-scale, quatrefoil, topographic, circuit, sparkles, vignette, mosaic, pixel-mosaic, pixel-mosaic-full.
Two rendering mechanisms, chosen per texture in section.blade.php's $textureDecorationHtml match block:
- Tailwind arbitrary-value gradients —
grid,graph-paper,dots,diagonal-lines, andcrosshatcharebg-[linear-gradient(...)]/bg-[radial-gradient(...)]utilities with an explicitdark:colour-pair variant (--color-tone-*swapped for a darker shade);isometric,diagonal-hatch, andconcentricuse a dual black/whitergba()emboss (repeating-linear-gradient/repeating-radial-gradient) so they read on light and dark backgrounds without a separatedark:variant;vignetteis a single radialrgba(0,0,0,…)darkening fade. - Inline SVG patterns — the rest (
blueprint,plus-marks,hex-mesh,triangle-mesh,brick,herringbone,chevron,fish-scale,quatrefoil,topographic,circuit,sparkles,mosaic,pixel-mosaic,pixel-mosaic-full) are built by dedicatedSection::{name}Svg()PHP methods that emit a tiling<pattern>— several of them (pixel-mosaic,mosaic,hex-mesh,topographic, …) use a fixed RNG seed (mt_srand(N)) so the generated markup is byte-stable across renders.
Two companion fields tune every texture:
section_texture_opacity— fades the tuned baked strength toward invisible.''or100renders at full (tuned) strength; the value multiplies the whole decoration's CSSopacity.section_texture_fade— a fade-direction enum (Centerdefault /Edges/Top/Bottom/Even — no fade) resolved bySection::textureMaskCss()into a CSSmask-image. Only applies to the line/grid-style textures listed inSection::textureCenterMaskStops(); textures with their own intrinsic masking (vignette,pixel-mosaic,pixel-mosaic-full) ignore it.
All textures:
- Are static — no
@keyframes, no JavaScript runtime, nothing to pause or reduced-motion-gate - Cost only the inline CSS/SVG emitted for that one section; no per-page asset, no extra HTTP request
- Are neutral (black/white/tone-based), not brand-coloured — unlike the video and canvas tiers, texture doesn't reference
primary/secondaryCSS variables - Can also be set as a section preset default (
preset['texture']) so every row using that preset inherits the same look unless a row overridessection_textureitself
v2: Editor-uploaded looping video backgrounds
Editors can upload an MP4 / WebM / MOV video to the media library and apply it as a section background. The system handles:
- Video uploads in the media library. The library accepts video files up to 100 MB; videos appear in the grid with a play-icon overlay and render their first frame as a thumbnail via
preload="metadata"(no full download). Existing image-only behaviour is preserved for every other media surface. - A picker filter (
kindFilter). Pickers can be opened in video-only mode, image-only mode, or "show all" mode. The bg-video field uses video-only. Existing pickers (post hero, gallery, blog featured, etc.) keep their image-only behaviour with zero migration. - The poster belongs to the video's media-library item, not the section. There is no per-section poster field. At upload time the browser extracts the first frame to a JPEG (canvas-based, ~250ms, no server roundtrip, no ffmpeg) and the server stores it as a
role=posterMediaItem linked to the video viamedia_items.poster_media_id(hidden from browse grids). Any video that lacks a poster — uploaded elsewhere, or whose poster was removed — can have one generated on demand from its media-library detail panel (Regenerate from video / Upload / Remove). Extraction is best-effort: if the browser can't decode the format the video still uploads cleanly and the poster can be added later. - Rendering. The video renders as
<video autoplay muted loop playsinline preload="metadata">positioned absolutely behind section content (-z-20). Theposter=attribute is resolved at render from the video's linked poster (MediaItem::posterUrlForPath(), query-free + cache-busted) — no per-placement poster field. An optional darkening overlay improves text legibility.
The video plays from the public storage URL directly — no Glide processing, no streaming server. Browsers handle progressive download natively.
v3: Canvas-based animated backgrounds
Eleven built-in presets that paint into a <canvas> behind the section content. Each preset reads brand colour CSS variables (--color-primary, --color-secondary, --color-tone-500) at init time, so the look automatically matches the install's branding — and every preset also exposes its own tweakable settings (see below) for editors who want to push past the defaults.
Canvas 2D presets:
- Particle Field — small bright points drifting in a chosen direction (up / down / left / right), wrapping seamlessly. Subtle enough to sit behind dense content. Fades out toward the bottom of the section by default.
- Gradient Orbs — 2–6 concentrated bright blurred spheres moving with parallax-style depth offsets.
- Lattice Waves — geometric grid that ripples in 3D-style waves, with sparse highlight dots at intersections. Modern / technical feel.
- Color Sweep — single smooth multi-stop linear gradient sweeping across the section at a configurable angle. Minimal motion, low CPU, good when you want subtle.
- Constellation — drifting dots connected by faint lines whose opacity scales with proximity. Reads as tech / connectivity / network. Good for SaaS, infrastructure, security pages.
- Halftone Shift — grid of brand-coloured dots whose radii pulse with a travelling wave. Print-style halftone pattern with slow visible motion. Editorial / design-y vibe — best paired with a subtle dark overlay if foreground text density is high.
- Bokeh Drift — large soft out-of-focus light discs drifting slowly with a gentle breathing pulse. Photographic depth-of-field look. Warm / atmospheric, good for portfolios + brand pages.
- Fireflies — small glowing points wandering on organic paths, each blinking on its own rhythm. Warm and calm; suited to dark or nature-themed sections.
- Wave Ribbons — layered sine ribbons flowing horizontally, weaving in and out of each other. A handful of strokes per frame — very low CPU.
WebGL presets (GPU fragment shaders; each falls back to a Canvas 2D preset when WebGL2 is unavailable or compilation fails):
- Night Sky — the view from the ground on a spinning earth: three star layers plus a hazy Milky Way band (fbm clouds with dark dust lanes) rotate rigidly around a celestial pole, so stars trace slow arcs whose local direction varies across the section — nothing scrolls in a single straight line. Occasional shooting stars streak across. Deep-night background subtly tinted by the brand primary at the horizon. Falls back to Particle Field.
- Aurora Veil — northern-lights curtains hanging from an undulating ridge near the top, with shimmering vertical rays through two depth layers. Color blends primary → secondary down each curtain; transparent background so it composites over the section's own bg color. Falls back to Gradient Orbs.
Tweakable parameters. Every canvas preset exposes per-section controls in the row paintbrush, under the canvas accordion — sliders (density, size, speed, intensity, …) and dropdowns (color family, drift direction, rotation center, …), plus a shared Edge fade select that masks the canvas out toward the top, bottom, or both edges. The param schema lives in Section::canvasPresetParams() and is consumed by both the editor UI and the runtime. Values are stored as JSON in content_overrides.section_animated_canvas_params and emitted on the canvas as data-anim-params="{...}". WebGL presets receive numeric values as u_{key} uniforms; Canvas 2D presets receive the sanitized object as state.params; the fade key is consumed server-side as a CSS mask on the canvas element. Defaults declared in PHP match the fallbacks inside each preset, so "no params saved" and "all controls at default" render identically.
All presets:
- Are lazy-loaded — the runtime (resources/js/animated-bg/) only loads when there's at least one
<canvas data-anim-preset>on the page, and Vite emits one async chunk per preset so only mounted presets download. Pages without a canvas pay zero bytes. - Auto-pause via
IntersectionObserverwhen the section scrolls off-screen, so CPU isn't burning on a hero the visitor can no longer see - Honour
prefers-reduced-motion— the first frame paints, then the loop stops - DPI-aware — canvas internal resolution = displayed pixels ×
devicePixelRatio(capped at 2 to avoid burning CPU on Retina 5K displays) - Resize-aware via
ResizeObserver— section size changes (responsive breakpoints, content reflow) re-paint without reload
WebGL specifics:
- Fullscreen-triangle vertex shader (no VBO needed — positions come from
gl_VertexID) and a per-preset fragment shader getContext('webgl2')is requested withpowerPreference: 'low-power'so laptops on battery don't switch to the discrete GPU- WebGL context loss (
webglcontextlost/webglcontextrestored) rebuilds the program + viewport on restore
Higher CPU than the static texture tier. Each canvas runs its own RAF loop costing ~1-3% sustained CPU on a modern laptop (the WebGL presets do their work per-pixel on the GPU, keeping the main thread mostly free). Reserve canvas presets for the one hero per page that needs to feel premium; use the static texture overlays for everything else.
How it works
The implementation has three layers:
1. Section schema fields
All fields live on <x-dl.section> (the wrapper every row uses), registered by app/Support/DlSchemas/Section.php:
| Field | Type | Purpose |
|---|---|---|
section_texture |
text (preset key) | Selects one of the 22 static texture overlays (see v1 above), or '' for none |
section_texture_opacity |
text (0-100) | Fades the texture's tuned baked strength. '' or 100 = full strength |
section_texture_fade |
text (enum) | Fade direction for line/grid-style textures — '' (center), edges, top, bottom, even |
section_bg_video |
video (path) | Storage path of the uploaded video file. Its poster is resolved at render from the video's linked media-library poster — there is no separate poster field. |
section_bg_video_overlay_classes |
classes | Optional Tailwind classes for an overlay div on top of the video (e.g. bg-tone-900/40) |
section_bg_video_mobile |
toggle | Off (default) skips the video <source> on phones (media="(min-width: 768px)") and shows the responsive poster image instead; on plays the video everywhere |
section_animated_canvas |
text (preset key) | Selects one of the canvas presets — Canvas 2D: particle-field, gradient-orbs, lattice-waves, color-sweep, constellation, halftone-shift, bokeh-drift, fireflies, wave-ribbons; WebGL: night-sky, aurora-veil |
section_animated_canvas_overlay_classes |
classes | Optional Tailwind classes for an overlay div on top of the canvas. In the editor this is set through the overlay tint composer (a color swatch + strength slider that writes bg-black/40-style utilities); a "Type classes instead" escape hatch accepts arbitrary classes. Only renders when a canvas preset is also set. |
section_animated_canvas_params |
text (JSON) | Per-preset tweakable parameters as a JSON object (e.g. {"density":1.5,"direction":"down","fade":"none"}). The editor's row paintbrush renders one control per declared param (slider or dropdown) under the canvas accordion. Numeric values feed WebGL u_{key} uniforms; the whole object is exposed to Canvas 2D presets as state.params; the fade key drives a server-side CSS mask. Garbage values fall back to the preset's declared default; the attribute is omitted entirely on malformed JSON. |
Any of the texture/video/canvas values can also be set at the section-preset level (preset['texture'], preset['bg_video'], preset['animated_canvas'], …) so every row using that preset inherits the same background unless a row sets its own value — see $ownTexture / $sectionTexture in section.blade.php for the resolution order.
2. The Section blade renders the decorations
resources/views/components/dl/section.blade.php emits absolute-positioned decoration <div> (or <video> / <canvas>) elements as the first children of the section, behind the content container:
- Video bg (
-z-20) — full-bleed<video>with autoplay/loop/muted/playsinline + a responsive poster<img>layer resolved from the video's linked MediaItem poster, plus an optional overlay div at-z-10 - Canvas bg (
-z-20) — full-bleed<canvas data-anim-preset="...">lazy-bound by resources/js/animated-bg/ - Texture decoration (
-z-10) — the static CSS/SVG pattern for the selectedsection_texture, optionally wrapped in an opacity<div>(section_texture_opacity) and amask-image<div>(section_texture_fadeviaSection::textureMaskCss())
When any decoration (texture, video, canvas, or a shape divider) is set, the section's class list is augmented with relative isolate overflow-hidden so the negative-z children stack correctly behind content without escaping the section.
3. Editor row paintbrush UI
All three tiers surface in the row's paintbrush ("Design") panel under the Style tab, as sub-accordions inside the Section Background group:
- v1 (Texture) — a "Texture" sub-accordion: a
<select>populated fromSection::textureOptions()(writessection_texture), an opacity slider shown once a texture is picked (section_texture_opacity), a fade<select>shown only for the line/grid textures listed inSection::textureCenterMaskStops()(section_texture_fade), and a "Preview options" eye button that opens a live per-texture visual picker (open-section-option-preview,kind: 'texture'). Adding a new texture is one entry inSection::textureOptions()plus adefault =>branch in section.blade.php's match block. - v2 (Video) — a "Video" sub-accordion with: pick-from-media-library (video-only), a video thumbnail preview with a remove button, the overlay-tint composer for
section_bg_video_overlay_classes, and a "Play video on mobile" checkbox forsection_bg_video_mobile(shown once a video is picked). The poster is managed on the video in the media library (a note in the accordion points there), so there's no per-section poster picker. The video picker opens the editor's pure-Alpine media picker pre-filtered to video-only.
The Alpine media picker's kindFilter prop is plumbed end-to-end:
EditorMediaActions::openRowDesignVideoPickerdispatchesrequest-open-media-pickerwithkindFilter: 'video'EditorModals::onRequestOpenMediaPickerforwards it to theopen-media-picker-modalwindow event- The Alpine factory in resources/js/editor/media-picker-modal.js passes it to the server RPCs (
loadMediaPickerInitialPayload,loadMediaPickerPagePayload) as a query filter - The picker shell template branches per-item between
<img>and<video>based on the item'skind
Texture rendering details
Every texture resolves through the same match ($sectionTexture) block in section.blade.php, landing on one of two implementations:
| Mechanism | Textures | How it reads on dark backgrounds |
|---|---|---|
Tailwind arbitrary-value gradient, dark: variant |
grid, graph-paper, dots, diagonal-lines, crosshatch |
A second dark:bg-[...] declaration swaps in a darker --color-tone-* line/dot colour |
Tailwind arbitrary-value gradient, dual-tone rgba() emboss |
isometric, diagonal-hatch, concentric |
A black layer and a white layer at low opacity stack in the same gradient, so no dark: variant is needed |
Single radial rgba(0,0,0,…) fade |
vignette |
N/A — it's a corner-darkening vignette, not a colour-adaptive pattern |
Inline SVG <pattern> (Section::{name}Svg()) |
blueprint, plus-marks, hex-mesh, triangle-mesh, brick, herringbone, chevron, fish-scale, quatrefoil, topographic, circuit, sparkles, mosaic, pixel-mosaic, pixel-mosaic-full |
Most use the same black+white dual-tone emboss layering as the CSS group; several (pixel-mosaic, pixel-mosaic-full, mosaic) additionally randomize per-cell fill/opacity from a fixed mt_srand() seed, so the generated SVG markup is byte-stable across renders despite looking hand-scattered |
section_texture_opacity wraps the whole decoration in an outer <div style="opacity:…"> (skipped when ''/100). section_texture_fade resolves through Section::textureMaskCss() into a mask-image / -webkit-mask-image wrapper <div> — a radial center mask by default (tuned per-texture stops in Section::textureCenterMaskStops()), or a directional linear-gradient mask for top/bottom, or none for even. Textures not listed in textureCenterMaskStops() (vignette, pixel-mosaic, pixel-mosaic-full) carry their own intrinsic shape and ignore the fade control entirely.
Nothing here animates — there's no @keyframes, no will-change, no prefers-reduced-motion handling, because there's no motion to reduce.
Video performance and best practices
For editor guidance on uploading effective background videos:
- Encoding. MP4 with H.264 video codec is the universal pick — every modern browser plays it. WebM/VP9 produces smaller files but has gaps on iOS Safari versions <13.
- Length. Hero loops should be 5–15 seconds — long enough to feel alive, short enough that the seam is hard to notice.
- Resolution. 1920×1080 is the sweet spot for hero backgrounds. Going to 4K typically isn't worth the bandwidth — the user is going to scroll past it in a few seconds.
- Bitrate. Aim for under 5 Mbps for a 10-second loop, which lands around 6 MB encoded. Files over 50 MB will feel sluggish on first paint even with
preload="metadata". - Audio. Always strip the audio track before upload. The browser will autoplay the video muted regardless, but the audio bytes are dead weight on the wire.
- Loop seamlessness. Hand-edit the first and last frames to match so the loop point doesn't visually pop. Avoid videos with cuts or scene changes — they read as "broken" when looped.
- Poster image. The system auto-extracts a poster from frame 0 at upload time, so most editors don't need to think about this. If you do want a custom poster (e.g. the first frame is too dark or empty), open the video's Media Library detail panel and use Regenerate from video / Upload / Remove — there is no per-section poster picker.
- Mobile. Verify on a real mobile device before shipping. Some iOS versions still drop autoplay if the browser tab isn't visible at decode time;
playsinline+mutedare required (the renderer sets both automatically).
Storage model
Videos use the same year/month folder layout as images (storage/app/public/{Y}/{m}/file.mp4) and live in the same media_items table. A new kind column (image | video) was added in migration 2026_05_26_201116_add_kind_to_media_items_table; existing rows are backfilled to image. The column is indexed so picker queries filter cheaply.
A second migration (2026_05_26_204745_add_poster_media_id_to_media_items_table) adds a self-referential poster_media_id FK so a video MediaItem can point at the image MediaItem auto-extracted from its first frame. nullOnDelete is set on the FK so deleting the poster leaves the video usable (the video keeps playing; the bg-video field just needs a new poster picked manually). The relationship is exposed as MediaItem::poster() and surfaced on the picker payload as posterMediaId so the editor can resolve the link without a second query.
Videos bypass the on-upload ImageResizer::resizeAbsolutePath step (the image resizer can't read video bytes and would corrupt them) and the getimagesize dimension probe (returns false for video). They therefore have null width/height. mime_type is set from the file's reported mime; kind is set from the video/ mime prefix.
Replace / rename / delete behaviour matches images: replacing keeps the path and updates the file in place (consumers pick up the new bytes via the ?v={updated_at} cache buster on url()); deleting nulls path-based references in content_overrides and lets the FK delete cascade kill pivot rows.
What's not included
These were considered and deliberately deferred or left out:
- Server-side video transcoding (e.g. ffmpeg). Posters are extracted client-side at upload (canvas-based, ~250ms, no system dependency). Transcoding videos to alternate codecs / bitrates / resolutions on the server would require ffmpeg as a system binary, plus a queue + retry layer — out of scope for the current need. Editors who want optimised files can encode locally before upload.
- Fluid simulation shader preset. Considered for v5 but deferred — a real fluid sim needs multi-pass framebuffer ping-pong + several buffer textures, which would balloon the runtime well beyond the current ~5.5 KB gzipped budget. The v5 WebGL presets cover the premium-hero look with single-pass fragment shaders. Revisit if there's specific demand.
- Three.js or other shader libraries. Hand-rolled WebGL2 setup is ~1 KB of code; pulling in Three.js for the shader runtime would add ~80 KB gzipped for marginal benefit on what are fundamentally 2D fullscreen fragment shaders.
- Custom shader authoring in the editor. Picking a preset is the only entry point — editors can't write or paste GLSL. Custom CSS via the section's Custom CSS field is the escape hatch for unusual visual needs.
- YouTube / Vimeo background loops. Out of scope — they require third-party consent gating, can't be reliably muted on iOS Safari, and can't have a controlled aspect ratio. Use the v2 self-hosted path instead.
- Multiple stacked video layers. Each section gets one bg-video. Layering multiple is conceptually possible but the UX cost (more pickers, more state) outweighs the use case.
- Per-texture tuning beyond opacity and fade. The texture tier exposes only
section_texture_opacityandsection_texture_fade— no per-texture colour, scale, or spacing controls; editors who need a different look pick a different texture, or override via the section's Custom CSS field. (The canvas presets are the opposite call — every one of them ships several per-section parameter controls, since motion, density, and color are the whole point of picking one.)
Files touched
For maintainers — the canonical implementation surface:
| File | Purpose |
|---|---|
app/Support/DlSchemas/Section.php |
Schema fields, textureOptions(), textureFadeOptions(), textureCenterMaskStops(), textureMaskCss(), the per-texture {name}Svg() builders, canvasPresetOptions() / canvasPresetParams(), mime → kind helpers |
resources/views/components/dl/section.blade.php |
Decoration rendering (video, canvas, static texture) |
resources/css/public.css |
Overlay-opacity safelist consumed by the video/canvas overlay-tint composer (no texture-specific CSS lives here — texture classes/SVG are emitted inline per render) |
app/Models/MediaItem.php |
kind column fillable, kindForMime() + isVideo() helpers, video-aware createForStoragePath(), rename regex |
database/migrations/2026_05_26_201116_add_kind_to_media_items_table.php |
Adds kind enum-style column with indexed default |
database/migrations/2026_05_26_204745_add_poster_media_id_to_media_items_table.php |
Adds self-referential poster_media_id FK |
app/Http/Controllers/MediaUploadController.php |
Editor's direct-upload endpoint: accepts video mimes + paired posters, 100 MB cap |
resources/views/pages/dashboard/media-library/⚡{index,picker}.blade.php |
Standalone library page + legacy Livewire picker: video upload + grid + edit modal |
app/Support/Rows/RowFieldParser.php |
sectionExcludedKeys() / sectionDesignKeys() — derived from Section::schemaFields(), so section_texture* and the video/canvas fields are excluded from content-sidebar hydration automatically |
app/Concerns/EditorMediaActions.php |
openRowDesignVideoPicker + companion picker/remove/apply methods |
app/Concerns/EditorMediaPickerActions.php |
RPC kindFilter param + kind / mimeType in item payload |
app/Livewire/EditorModals.php |
Forwards kindFilter from request event to picker open event |
resources/js/editor/media-picker-modal.js |
Alpine state kindFilter, plumbed through handleOpen + page loads; client-side extractVideoFirstFrame + paired poster upload |
resources/js/editor/editor-state.js |
Routes row-design-bg-video: and row-design-bg-video-poster: picker keys |
resources/views/livewire/editor-modals-partials/media-picker-shell.blade.php |
Renders <video> per item + dynamic accept attr |
resources/views/pages/dashboard/pages/⚡editor.blade.php |
"Texture" sub-accordion + "Video" sub-accordion + "Animated canvas" picker + section_* allowlist sync |
resources/js/animated-bg/ |
Canvas 2D (v3, v3.1) + WebGL2 (v5) runtime. index.js is the entry (lifecycle, painters, lazy preset loader); shared.js + shared-glsl.js hold runtime helpers; presets/{name}.js is one ES module per preset. Vite emits one async chunk per preset via import.meta.glob('./presets/*.js'), so pages only download chunks for presets actually present in the DOM (~1-2 KB per preset on top of the ~8 KB index, vs the old ~30 KB monolithic runtime). |
resources/js/public.js |
Detects canvas[data-anim-preset] on DOMContentLoaded and dynamic-imports the runtime |
tests/Feature/SectionTextureFadeTest.php |
v1 texture fade-mask + option-set render tests |
tests/Feature/SectionBgVideoTest.php |
v2 video bg render tests + kindForMime |
tests/Feature/MediaUploadControllerVideoPosterTest.php |
v2.1 paired upload + poster_media_id link tests |
tests/Feature/SectionAnimatedCanvasTest.php |
v3 canvas preset render + allowlist + dataset attr tests |