diklein
componentdk-bezelersource: ok · refreshed
DKBezeler
Display components for DKBezeler's baked device frames: a framed still as a plain image, and the light and dark video versions swapped with CSS. Bake the assets with npx @diklein/dkbezeler.
npx shadcn add "https://diklein.com/r/dk-bezeler.json"free
Visual style
dark-futuristic
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Files3 · source cached 10/7/2026, 4:36:36 PM
registry/dk-bezeler/dk-bezeler.tsx
import type { ReactNode } from 'react'
/* Display components for DKBezeler's baked outputs. Both are deliberately plain:
* the bake did the hard part, so a framed still is just an image and a framed clip
* is just a pair of videos. No dependencies; the only client JS is the clip pair's
* prefers-reduced-motion check, kept in dk-bezel-video.tsx so this file stays
* server-safe (renderImage takes a function, which can't cross a client boundary). */
export { DKBezelVideo, type DKBezelVideoProps } from './dk-bezel-video'
export interface DKBezelImageProps {
/** The baked -framed.png. */
src: string
alt?: string
/** Intrinsic pixels of the baked PNG (dkbezeler prints them). Declaring them
* reserves layout before the file arrives. */
width: number
height: number
/** Display width cap. The bake is transparent outside the device, so it sits on
* any background. */
maxWidth?: number
className?: string
/** Render the image yourself (e.g. with next/image). */
renderImage?: (src: string, ctx: { alt: string; className: string }) => ReactNode
}
export function DKBezelImage({ src, alt, width, height, maxWidth = 460, className, renderImage }: DKBezelImageProps) {
return (
<figure className={`dkbz-figure${className ? ` ${className}` : ''}`} style={{ maxWidth }}>
{renderImage ? (
renderImage(src, { alt: alt ?? '', className: 'dkbz-img' })
) : (
<img src={src} alt={alt ?? ''} width={width} height={height} className="dkbz-img" loading="lazy" />
)}
</figure>
)
}
registry/dk-bezeler/dk-bezel-video.tsx
'use client'
/* The framed-clip half of DKBezeler's display pair — a client component because the
* prefers-reduced-motion check needs matchMedia. It lives in its own file so
* dk-bezeler.tsx stays server-safe (DKBezelImage's renderImage takes a function,
* which can't cross a client boundary); dk-bezeler.tsx re-exports it, so consumers
* keep one import path. */
import { useEffect, useRef, useState } from 'react'
export interface DKBezelVideoProps {
/** Base path of the baked pair: `${base}-light.mp4`, `${base}-dark.mp4` and the
* matching .jpg posters, exactly as dkbezeler wrote them. */
base: string
/** Accessible label for the clip. */
alt?: string
/** Intrinsic pixels of the baked videos (dkbezeler prints them). Declares the
* aspect ratio so the box holds its size before video metadata arrives (a bare
* <video> is 300x150 until then, which costs CLS). */
width: number
height: number
maxWidth?: number
className?: string
}
/**
* H.264 has no alpha, so the bake writes a light version and a dark version with
* corners matched to each theme's page background. Both render; CSS shows exactly one
* (`.dark` class convention, falling back to prefers-color-scheme). The hidden one
* is display:none, so it never fetches past metadata or plays. Under
* prefers-reduced-motion the clip holds its poster instead of looping — controls
* appear so it can still be played by choice.
*/
export function DKBezelVideo({ base, alt, width, height, maxWidth = 460, className }: DKBezelVideoProps) {
// Reduced-motion visitors get the poster, not a looping clip: autoPlay drops and controls
// appear so the video stays reachable on demand. Read in an effect so the SSR markup stays
// stable; live-updated because the OS toggle can flip mid-visit.
const [reducedMotion, setReducedMotion] = useState(false)
const figureRef = useRef<HTMLElement>(null)
useEffect(() => {
const mq = window.matchMedia('(prefers-reduced-motion: reduce)')
const update = () => setReducedMotion(mq.matches)
update()
mq.addEventListener('change', update)
return () => mq.removeEventListener('change', update)
}, [])
// Playing clips don't react to the autoPlay attribute changing, so a live preference flip is
// applied by hand. Resume only the visible theme's clip — the display:none one must keep
// never fetching.
useEffect(() => {
figureRef.current?.querySelectorAll('video').forEach((video) => {
if (reducedMotion) video.pause()
else if (video.offsetParent !== null) video.play().catch(() => {})
})
}, [reducedMotion])
return (
<figure ref={figureRef} className={`dkbz-figure${className ? ` ${className}` : ''}`} style={{ maxWidth }}>
{(['light', 'dark'] as const).map((theme) => (
<video
key={theme}
className={`dkbz-video dkbz-${theme}`}
src={`${base}-${theme}.mp4`}
poster={`${base}-${theme}.jpg`}
style={{ aspectRatio: `${width} / ${height}` }}
autoPlay={!reducedMotion}
controls={reducedMotion}
muted
loop
playsInline
aria-label={alt}
/>
))}
</figure>
)
}
registry/dk-bezeler/dk-bezeler.css
/* DKBezeler display components. Theme switching follows the shadcn convention
* (a `.dark` class on <html>) and falls back to prefers-color-scheme when no
* theme class is in play. */
.dkbz-figure {
margin: 0 auto;
width: 100%;
}
.dkbz-img,
.dkbz-video {
display: block;
width: 100%;
height: auto;
}
/* Exactly one master visible. The hidden one is display:none, so it never plays
* or fetches past metadata. */
.dkbz-dark {
display: none;
}
.dark .dkbz-light {
display: none;
}
.dark .dkbz-dark {
display: block;
}
@media (prefers-color-scheme: dark) {
:where(html:not(.light)) .dkbz-light {
display: none;
}
:where(html:not(.light)) .dkbz-dark {
display: block;
}
}