---
title: SC Image Lightbox
description: A single image that opens into a zoomable, keyboard-friendly dialog.
url: https://secretocho.design/lab/sc-image-lightbox/
type: SoftwareSourceCode
status: working
published: 2026-10-05
---

# SC Image Lightbox

> A single image that opens into a zoomable, keyboard-friendly dialog.

component 24 / 28 · WORKING

- **type**: Code Component
- **platform**: Framer
- **license**: SecretoCho Design

![A still of the SC Image Lightbox demo, as its module draws it with reduced motion.](https://secretocho.design/_astro/still.C3AWmCsM_rTFBl.webp)

*fig 00 · live demo, ported from **ScImageLightbox.tsx***

One-image lightbox: themed thumbnail with overlay icon, portal dialog with zoom, pan, pinch, fullscreen and keyboard, scroll lock that restores position.

ScImageLightbox is a single image that opens full-screen. The thumbnail keeps an aspect ratio, radius and shadow and shows a dashed overlay icon; a click, Enter or Space opens a portal dialog with the alt text, zoom buttons and read-out, fullscreen and close. The image zooms to 3× by buttons, keys, ctrl/cmd + wheel or pinch, pans by drag when zoomed, and the page behind stays put.

- Media
- UI

## Main

### Overview

- **Built With**: React (hooks), react-dom createPortal, framer-motion, Fullscreen API, Framer Code Component API
- **Layout Support**: Any size (prefers fixed); optional aspect ratio on the thumbnail
- **Orientation**: Thumbnail in place; lightbox fixed over the page with a top bar and bottom hints
- **Ideal Use Case**: Blog images, case-study screenshots, any image that deserves a closer look

### Tags

- lightbox
- image
- zoom
- portal
- pinch
- fullscreen
- dialog
- framer-motion

### Code comment

*ScImageLightbox.tsx · lines 1–3 · the file’s header comment*

```tsx
// ScImageLightbox_Optimized.tsx - SecretoCho™ Image Lightbox Component
// Optimized version with simplified layout and better performance
// ready_to_ship=true
```

### Dependencies

- **Technologies**: framer-motion, react-dom (both in the Framer runtime)
- **Notes**: Takes a Framer Responsive Image (src, srcset, alt).

### Complexity

- **Logic Level**: Medium (open/close with scroll lock, zoom and drag maths, pinch, keyboard, fullscreen)
- **Runtime Cost**: Low (the dialog renders only while open; transitions by framer-motion)
- **Component Size**: ~48 KB TSX (48,171 bytes, 1,129 lines)
- **Notes**: Device checks (width &lt; 768, touch) are measured once on mount.

## Guide

### Instructions

1. Drop **ScImageLightbox** into a frame and set the **Image** (with alt text).
2. Pick an **Aspect Ratio** (Auto, 1:1, 16:9, 2:1, 3:4), **Border Radius**, **Box Shadow** and **Container Background**.
3. **Overlay Icon**: show, position (corners or centre) and icon (expand, zoom, eye, plus, arrows).
4. **Lightbox Settings**: background, backdrop blur, alt text, the close / zoom / fullscreen buttons, and the fade / scale / slide animation.

### Examples / Presets

- Default
  
  The source defaults: auto ratio, radius 8, expand icon top right, dark blurred lightbox, scale animation, all three buttons.
  
- As on the live site
  
  - **borderRadius**: 0
  - **overlay.position**: top-left
  - **lightbox.backdropBlur**: true (the old site’s eight app case-study instances; their W-B 80 % frame and page-colour lightbox background come from the demo module)
  
- Centre zoom icon
  
  - **aspectRatio**: video (16:9)
  - **borderRadius**: 16
  - **overlay.position**: center
  - **overlay.icon**: zoom
  - **lightbox.animation**: fade
  
- Slide, no controls
  
  - **aspectRatio**: square
  - **overlay.position**: bottom-right
  - **overlay.icon**: plus
  - **lightbox.animation**: slide
  - **lightbox.showAltText**: false
  - **lightbox.backdropBlur**: false
  - **controls.zoom**: false
  - **controls.fullscreen**: false
  

## Mechanics

### How it Works

- **Thumbnail**: a motion.div with role="button", tabIndex 0 and an aria-label; hover scales 1.02, tap 0.98; the lazy-loaded \<img\> covers it and the overlay icon fades in once the image has loaded.
- **Open**: window.scrollY is stored and the body set to position: fixed, top −scrollY, width 100% and overflow-y: scroll; the dialog (role="dialog", aria-modal) is rendered through createPortal into document.body.
- **Close**: Esc or the close button resets zoom, drag and the fullscreen flag, clears the body styles and scrolls back to the stored position. The overlay has a click-to-close handler, but the full-size image layer and both bars stop the click, so a click or tap on the backdrop does nothing. Closing is instant: the portal unmounts before the exit fade can run.
- **Zoom**: 0.5–3× in 0.25 steps (buttons, + / = and − / \_ keys, 0 to reset, ctrl/cmd + wheel); pinch sets zoom from the finger distance ratio; above 1× mousedown / touch drags the image within offsets clamped to (zoom − 1) × container / 2.
- **Fullscreen**: F or the button calls requestFullscreen on the dialog and exitFullscreen to leave.
- **Hints**: the bottom bar shows “ESC: Close • +/-: Zoom” (plus “Drag to pan” above 1×) on desktop, and “Pinch to zoom • Tap outside to close” (“Drag to pan • Pinch to zoom” above 1×) when the screen is under 768 px or has touch, plus the zoom percentage.

### Performance & Tuning

- The thumbnail is lazy-loaded; the dialog mounts only when open and unmounts on close.
- Backdrop Blur off removes only the overlay’s 12 px blur (and softens the image shadow); the top bar (20 px), the bottom bar (10 px) and the buttons (8 px) always blur.
- Drag updates set state on every mousemove; transitions are disabled while dragging to stay smooth.

### Compatibility & Limits

- Evergreen browsers; the unprefixed Fullscreen API with no feature check or error handling, so where it is missing or refused the call fails; framer-motion and react-dom come with Framer.
- isMobile (width \< 768) and hasTouch are measured once on mount; zoom and fullscreen buttons are hidden on mobile.
- Icon SVGs are inline strings from the file.

## Quality

### Accessibility

- The thumbnail is keyboard-operable (role="button", Enter / Space) with aria-label “Open lightbox for \<alt\>”; the dialog has role="dialog" and aria-modal="true"; every button has an aria-label.
- Esc closes; + / = and − / \_ zoom, 0 resets, F toggles fullscreen, whenever the dialog is open, even when those buttons are hidden or switched off.
- Focus is not moved into the dialog or trapped, and a click or tap on the backdrop does not close it; the alt text is shown in the top bar when Show Alt Text is on.

### Security & Privacy

- **Network**: only the image’s own src / srcset (the built-in default is a framerusercontent.com image with the alt “SecretoCho Design”). No fetch, no tracking.
- **Permissions**: the Fullscreen API on a click or the F key; nothing else.
- No storage. dangerouslySetInnerHTML is used only for the file’s own inline SVG icon strings.
- A document keydown listener stays attached while the component is mounted (it ignores keys while closed); mousemove / mouseup / touchmove / touchend listeners are added only while dragging and removed after. The body’s position, top, width and overflow-y are cleared on close, but not if the component unmounts while open.

## Reference

### Props

| name | type | default | description |
| --- | --- | --- | --- |
| image | ResponsiveImage | a framerusercontent.com image, alt “SecretoCho Design” | Image. src, srcset and alt. |
| aspectRatio | enum | auto | Aspect Ratio. Auto \| 1:1 (square) \| 16:9 (video) \| 2:1 (wide) \| 3:4 (tall). |
| borderRadius | number | 8 | Border Radius 0–50 px on the thumbnail. |
| boxShadow | string | 0 4px 14px rgba(0,0,0,.08) | Box Shadow. A CSS shadow string. |
| containerBackground | color | #FAFAFA | Container Background behind the thumbnail. |
| overlay | object | show=true, position=top-right, icon=expand | Overlay Icon. Show Icon; Top Right \| Top Left \| Bottom Right \| Bottom Left \| Center; Expand \| Zoom \| Eye \| Plus \| Arrows. |
| lightbox | object | backgroundColor=rgba(0, 0, 0, 0.95), backdropBlur=true, showAltText=true, controls.close=true, controls.zoom=true, controls.fullscreen=true, animation=scale | Lightbox Settings. Background, Backdrop Blur, Show Alt Text, Controls (Close, Zoom, Fullscreen), Animation Fade \| Scale \| Slide. |

The close button hides only when controls.close is explicitly false; zoom and fullscreen buttons are also hidden on screens under 768 px regardless of the controls.

### Notes & Tips

- Always set alt: it labels the thumbnail button and the top bar.
- Auto adds no ratio: the thumbnail fills its frame and crops the image to it (object-fit: cover), so size the frame to the image to avoid cropping; at 1× the lightbox shows the whole image inside the viewport.
- Fade scales 0.95 → 1 over 0.25 s, Scale 0.8 → 1 over 0.3 s, and Slide rises 20 px over 0.3 s; all three fade in.

### Troubleshooting / FAQ

The overlay icon never appears

It shows after the thumbnail’s onLoad; a blocked or failed image keeps it hidden.

No zoom buttons on a phone

Hidden under 768 px; pinch zoom works.

Fullscreen does nothing

The browser refused or lacks requestFullscreen: the call is unprefixed, with no fallback or error handling. Under 768 px the button is not shown at all.

The page jumps when the lightbox opens

The body gets position: fixed with overflow-y: scroll to keep the scrollbar; a layout that depends on body position will shift.

The fullscreen icon is wrong after leaving fullscreen

Nothing listens for fullscreenchange, so leaving fullscreen another way (the browser’s own Esc) leaves the exit icon in place.

## Source

### Header comment

*ScImageLightbox.tsx · lines 1–3 · header comment*

```tsx
// ScImageLightbox_Optimized.tsx - SecretoCho™ Image Lightbox Component
// Optimized version with simplified layout and better performance
// ready_to_ship=true
```

[props \[REF\]](#reference)

### Key excerpt

Opening fixes the body at its current scroll offset so the page cannot scroll behind the dialog; closing clears those styles and scrolls back to the exact position.

*ScImageLightbox.tsx · lines 243–271 · tsx*

```tsx
    const handleOpen = useCallback(() => {
        if (typeof window === "undefined") return
        const scrollY = window.scrollY
        scrollPositionRef.current = scrollY
        setIsOpen(true)

        document.body.style.position = "fixed"
        document.body.style.top = `-${scrollY}px`
        document.body.style.width = "100%"
        document.body.style.overflowY = "scroll"
    }, [])

    const handleClose = useCallback(() => {
        setIsOpen(false)
        setZoomLevel(1)
        setIsFullscreen(false)
        setDragOffset({ x: 0, y: 0 })
        setIsDragging(false)

        const scrollY = scrollPositionRef.current
        document.body.style.position = ""
        document.body.style.top = ""
        document.body.style.width = ""
        document.body.style.overflowY = ""

        if (typeof window !== "undefined" && scrollY !== undefined) {
            window.scrollTo(0, scrollY)
        }
    }, [])
```

## Credits

### Credits

© SecretoCho Design

- **license**: SecretoCho Design

SecretoCho Design

### Disclaimer

Provided “as is” for visual/UX purposes. Validate performance, accessibility, and licensing within your project.

## Proves

- [Creative coding](https://secretocho.design/about/#capabilities)

## Hiring a design engineer?

I’m open to senior design engineer roles: remote from Belgrade, or relocation with visa sponsorship. Write to me, or read the CV first. Client work stays selective, one project at a time.

[sc@secretocho.design](mailto:sc@secretocho.design)

[read the CV](https://secretocho.design/cv/) · [LinkedIn](https://www.linkedin.com/in/secretocho/)
