shadimage
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;
  }
}

More from diklein

Browse all components in this library →