@mdsv/ratio

Purpose

Locks a container to specific aspect ratios (e.g., 2:1 landscape, 1:1 square, 1:2 portrait). Scrolling happens inside the container, not the viewport—great for consistent layouts across devices.

When to Use

  • Prototyping responsive layouts without managing complex media queries
  • Building mobile-first apps that need consistent container proportions
  • Creating draggable, touch-friendly UI components
  • Supporting devices with notches/cutouts (safe-area handling)

API Hints

<script lang="ts">
import Ratio from '@mdsv/ratio'
let currentMode = $state()
</script>

<Ratio
    modes={{
        'orientation: landscape': { width: 8, height: 5, padding: '4cqmin' },
        'orientation: portrait': { width: 5, height: 8, padding: null },
    }}
    defaultMode={{ width: 1, height: 1, padding: 0 }}
    bind:currentMode
    containerType="size"
>
    <!-- Your content here -->
</Ratio>

Quick Start

Use Case 1: Video Player

Videos have intrinsic aspect ratios. Stretched or cropped content looks unprofessional and hurts user experience.

Problem:

  • 16:9 video stretched to fit 4:3 screen → distorted
  • 4:3 video cropped to fit 16:9 screen → missing content
  • Inconsistent player sizes across devices

Solution:

<script lang="ts">
import Ratio from '@mdsv/ratio'

let isPlaying = $state(false)
</script>

<Ratio
    defaultMode={{ width: 16, height: 9, padding: 0 }}
    outerClass="flex items-center justify-center h-screen bg-black"
    innerClass="relative bg-gray-900 overflow-hidden rounded-lg"
>
    <video
        class="absolute inset-0 w-full h-full object-cover"
        src="/video.mp4"
        controls
    />
</Ratio>

Use Case 2: Flashcard Learning App

Modern learners have short attention spans. Long scrolling text loses engagement. Ratio enforces bite-sized content.

Traditional Education (Problem):

  • Pages of endless text
  • Users scroll, get bored, leave
  • Low retention, high dropoff

Flashcard Approach (Solution):

  • Fixed card size (e.g., 3:2 landscape or 2:3 portrait)
  • One concept per card
  • Swipe through content quickly
  • Higher engagement, better retention
<script lang="ts">
import Ratio from '@mdsv/ratio'

let currentCard = $state(0)
const flashcards = [
    { question: "What is React?", answer: "A UI library for building web apps" },
    { question: "What is a hook?", answer: "Functions that let you use state in function components" },
]

let showAnswer = $state(false)
</script>

<Ratio
    modes={{
        'orientation: landscape': { width: 3, height: 2, padding: '1rem' },
        'orientation: portrait': { width: 2, height: 3, padding: '1rem' },
    }}
    outerClass="flex items-center justify-center h-screen bg-gradient-to-br from-indigo-500 to-purple-600"
    innerClass="bg-white rounded-2xl shadow-2xl p-8 flex flex-col justify-center"
>
    <div class="text-center">
        <div class="text-sm text-gray-400 mb-2">
            Card {currentCard + 1} / {flashcards.length}
        </div>

        <h2 class="text-2xl font-bold text-gray-800 mb-8">
            {flashcards[currentCard].question}
        </h2>

        <button
            onclick={() => showAnswer = !showAnswer}
            class="px-6 py-3 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700"
        >
            {showAnswer ? "Hide Answer" : "Show Answer"}
        </button>

        {#if showAnswer}
            <p class="mt-6 text-gray-600 text-lg">
                {flashcards[currentCard].answer}
            </p>
        {/if}

        <div class="mt-8 flex gap-4 justify-center">
            <button
                onclick={() => { currentCard--; showAnswer = false }}
                disabled={currentCard === 0}
                class="px-4 py-2 bg-gray-200 rounded-lg disabled:opacity-50"
            >
                ← Previous
            </button>
            <button
                onclick={() => { currentCard++; showAnswer = false }}
                disabled={currentCard === flashcards.length - 1}
                class="px-4 py-2 bg-indigo-600 text-white rounded-lg disabled:opacity-50"
            >
                Next →
            </button>
        </div>
    </div>
</Ratio>

Why Ratio matters here:

  • Forces content into fixed dimensions → can’t stuff endless text
  • Consistent card size → predictable, swipeable experience
  • Responsive ratios → works on phone and desktop
  • Constraint = better UX, not limitation

Structure

┌─────────────────────────────────────────────────────────┐
│ outerClass                                              │
│ (Full viewport/parent container)                        │
│                                                         │
│  ┌─────────────────────────────────────────────────────┐│
│  │ positionClass                                       ││
│  │ (Handles centering + draggable positioning)         ││
│  │                                                     ││
│  │  ┌────────────────────────────────────────────────┐ ││
│  │  │ innerClass                                     │ ││
│  │  │ (Your content lives here)                      │ ││
│  │  │                                                │ ││
│  │  │ ┌───────────────────────────────────────────┐  │ ││
│  │  │ │                                           │  │ ││
│  │  │ │        Your Svelte Components             │  │ ││
│  │  │ │        (Aspect ratio enforced here)       │  │ ││
│  │  │ │                                           │  │ ││
│  │  │ └───────────────────────────────────────────┘  │ ││
│  │  └────────────────────────────────────────────────┘ ││
│  └─────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────┘

Props

PropTypeDefaultDescription
modesRecord<string, RatioMode>Media query → mode mapping
defaultModeRatioMode{width:1, height:1, padding:0}Fallback mode
currentModeRatioMode (bindable)Currently active mode
containerTypeCSS.Properties['containerType']'size'Container query type
outerClassClassValueOuter wrapper styles (full viewport/parent container)
positionClassClassValueStyles for positioning wrapper (handles centering + draggable placement)
innerClassClassValueInner content styles (your content lives here)
debugbooleanfalseEnable super debug mode (logs warnings and state changes to console)

RatioMode

{
  width: number
  height: number
  restrictWidth?: boolean  // Default: width < height
  restrictHeight?: boolean // Default: height < width
  padding: number | string | null
}

Debug Mode

When debug={true}, the component logs all internal events to the browser console:

State Changes:

  • Mode switches (old → new mode with media query trigger)
  • Aspect ratio changes (width × height)
  • Drag state changes (start, move, end with coordinates)

Warnings:

  • Invalid dimensions (zero, negative, or extreme ratios)
  • Container query not supported (fallback to defaultMode)
  • Content overflow detected
  • Fixed-position children detected (warns they may escape container)

Usage:

<Ratio
    debug={true}
    modes={{ ... }}
    bind:currentMode
>
    <!-- content -->
</Ratio>

TypeScript Exports

Ratio exports its types for use in your own code. This gives you full type safety when defining modes, callbacks, or working with component data.

RatioMode

Type definition for aspect ratio modes.

import type { RatioMode } from '@mdsv/ratio'

const myMode: RatioMode = {
    width: 16,
    height: 9,
    padding: 0
}

Usage in props:

<script lang="ts">
import Ratio, type { RatioMode } from '@mdsv/ratio'

let currentMode: RatioMode = $state({
    width: 16,
    height: 9,
    padding: 0
})

const modes: Record<string, RatioMode> = {
    landscape: { width: 16, height: 9, padding: 0 },
    square: { width: 1, height: 1, padding: '1rem' },
    portrait: { width: 9, height: 16, padding: '1rem' },
}
</script>

<Ratio
    modes={modes}
    defaultMode={currentMode}
    bind:currentMode
>
    <!-- content -->
</Ratio>

All Exports

// Type for aspect ratio modes
import type { RatioMode } from '@mdsv/ratio'

// Full component type (for generic components)
import Ratio from '@mdsv/ratio'

Why use types?

  • Catch dimension errors at compile time
  • Autocomplete in IDEs
  • Safer refactoring
  • Better team collaboration

Events

resizeStart

Fires when the user starts resizing the window (or container).

Payload:

{
    mode: RatioMode
    width: number
    height: number
    timestamp: number
}

resize

Fires during active resize (throttled).

Payload:

{
    mode: RatioMode
    width: number
    height: number
    timestamp: number
}

resizeEnd

Fires when resize activity has stopped for 1 second (idle period).

Payload:

{
    mode: RatioMode
    width: number
    height: number
    timestamp: number
}

modeChange

Fires when active mode switches (media query match changes).

Payload:

{
    oldMode: RatioMode | null
    newMode: RatioMode
    trigger: string  // The media query that triggered the switch
    timestamp: number
}

dragStart

Fires when dragging begins (if draggable).

Payload:

{
    x: number
    y: number
    timestamp: number
}

dragMove

Fires during dragging (if draggable).

Payload:

{
    x: number
    y: number
    deltaX: number
    deltaY: number
    timestamp: number
}

dragEnd

Fires when dragging ends (if draggable).

Payload:

{
    x: number
    y: number
    timestamp: number
}

Usage Example: Blur During Resize

Hide content during resizing to prevent jank, then restore when complete.

<script lang="ts">
import Ratio from '@mdsv/ratio'

let isResizing = $state(false)
let blurAmount = $state(0)

function handleResizeStart() {
    isResizing = true
    blurAmount = 8
}

function handleResizeEnd() {
    isResizing = false
    blurAmount = 0
}
</script>

<Ratio
    defaultMode={{ width: 16, height: 9, padding: 0 }}
    outerClass="flex items-center justify-center h-screen bg-black"
    innerClass="relative bg-gray-900 rounded-lg transition-all duration-200"
    class:backdrop-blur-sm={isResizing}
    on:resizeStart={handleResizeStart}
    on:resizeEnd={handleResizeEnd}
>
    <div style="filter: blur({blurAmount}px)" class="w-full h-full">
        <!-- Your content here (blurred during resize) -->
    </div>

    {#if isResizing}
        <div class="absolute inset-0 flex items-center justify-center bg-black/50">
            <p class="text-white">Resizing...</p>
        </div>
    {/if}
</Ratio>

Snippets (Svelte 5)

Ratio uses Svelte 5’s {#snippet} feature to provide flexible content placement. Snippets replace to legacy slot system and allow you to pass reusable markup blocks to the component.

children (implicit)

The main content inside <Ratio> tags is implicitly the children snippet. This is what renders inside the aspect-ratio container.

<Ratio
    defaultMode={{ width: 16, height: 9, padding: 0 }}
>
    <!-- This content is to implicit children snippet -->
    <video src="/video.mp4" controls />
</Ratio>

cornerControls (optional)

External buttons placed outside of the main container for draggable handles, ratio toggles, or other controls.

Accepted props:

{
    currentMode: RatioMode
    toggleRatio: () => void
    enableDrag: () => void
    disableDrag: () => void
    isDragging: boolean
}

Usage Example: Drag Handle + Ratio Toggle

<script lang="ts">
import Ratio from '@mdsv/ratio'

let currentMode = $state({ width: 16, height: 9, padding: 0 })
const modes = {
    landscape: { width: 16, height: 9, padding: 0 },
    square: { width: 1, height: 1, padding: 0 },
    portrait: { width: 9, height: 16, padding: 0 },
}

function toggleRatio() {
    // Cycle through modes
    const modeKeys = Object.keys(modes)
    const currentIndex = modeKeys.findIndex(
        key => modes[key] === currentMode
    )
    const nextIndex = (currentIndex + 1) % modeKeys.length
    currentMode = modes[modeKeys[nextIndex]]
}
</script>

<Ratio
    {currentMode}
    outerClass="flex items-center justify-center h-screen bg-black"
    innerClass="relative bg-gray-900 rounded-lg overflow-hidden"
>
    <!-- Main content (implicit children snippet) -->
    <video src="/video.mp4" controls class="absolute inset-0 w-full h-full object-cover" />

    <!-- Corner controls (explicit snippet) -->
    {#snippet cornerControls({ toggleRatio, isDragging })}
        <div class="absolute top-4 right-4 flex gap-2">
            <!-- Drag handle -->
            <button
                class="p-2 bg-white/10 backdrop-blur-md rounded-lg hover:bg-white/20 transition-colors"
                title="Drag to move"
            >
                <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
                    <path d="M5 9l-3 3 3 3M9 5l3-3 3 3M19 9l3 3-3 3M15 19l-3 3-3-3"/>
                </svg>
            </button>

            <!-- Ratio toggle -->
            <button
                onclick={toggleRatio}
                class="p-2 bg-white/10 backdrop-blur-md rounded-lg hover:bg-white/20 transition-colors"
                title="Toggle aspect ratio"
            >
                <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
                    <rect x="3" y="3" width="18" height="18" rx="2"/>
                    <path d="M9 9l6 6M15 9l-6 6"/>
                </svg>
            </button>

            <!-- Dragging indicator -->
            {#if isDragging}
                <div class="absolute -bottom-8 left-0 right-0 text-white/70 text-xs">
                    Dragging...
                </div>
            {/if}
        </div>
    {/snippet}
</Ratio>

Usage Example: Close Button + Status Badge

<Ratio
    defaultMode={{ width: 3, height: 2, padding: '1rem' }}
    innerClass="bg-white rounded-2xl shadow-2xl relative"
>
    <!-- Flashcard content -->
    <div class="p-8">
        <h2 class="text-2xl font-bold text-gray-800">
            What is Svelte 5?
        </h2>
        <p class="mt-4 text-gray-600">
            A reactive framework with new runes API.
        </p>
    </div>

    <!-- Corner controls -->
    {#snippet cornerControls({ currentMode })}
        <!-- Close button -->
        <button
            class="absolute top-4 right-4 p-2 text-gray-400 hover:text-gray-600"
            title="Close"
        >
            <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
                <path d="M18 6L6 18M6 6l12 12"/>
            </svg>
        </button>

        <!-- Mode badge -->
        <div class="absolute bottom-4 left-4 px-3 py-1 bg-indigo-100 text-indigo-700 text-sm rounded-full">
            {currentMode.width}:{currentMode.height}
        </div>
    {/snippet}
</Ratio>

Optional Snippets

The cornerControls snippet is optional. If not provided, no controls are rendered.

<!-- No corner controls - just to content -->
<Ratio defaultMode={{ width: 1, height: 1, padding: 0 }}>
    <div class="h-full w-full bg-gray-800">
        Content only, no controls
    </div>
</Ratio>

Multiple Snippets (Advanced)

You can pass multiple named snippets for different control areas:

<Ratio defaultMode={{ width: 16, height: 9, padding: 0 }}>
    <!-- Content -->
    <video src="/video.mp4" controls />

    <!-- Top-left controls -->
    {#snippet topLeftControls()}
        <button class="absolute top-4 left-4">Back</button>
    {/snippet}

    <!-- Bottom-right controls -->
    {#snippet bottomRightControls()}
        <button class="absolute bottom-4 right-4">Share</button>
    {/snippet}
</Ratio>

Caveats

Philosophy

Caveats document only unworkable technical limitations, not design choices.

Include: Browser support gaps, unconfigurable constraints, unavoidable side effects.

Exclude: Intentional design decisions, configurable behavior, solvable problems.

Browser Support

  • Container queries require Chrome 105+, Safari 16+, or Firefox 110+
  • Older browsers will always use defaultMode (no auto-switching)
  • Check caniuse.com for current support

Content Fit

  • Fixed ratios don’t adapt to content length — long text scrolls inside or clips
  • Not suitable for content-heavy pages (articles, documentation, blogs)
  • Design your content to fit the container, or use innerClass overflow handling

Mobile Viewport Quirks

  • Browser chrome (address bars, home indicators) dynamically expands/collapses on iOS/Android
  • Layout may shift when browser UI appears/disappears
  • Use safe-area aware values in padding for notch/cutout devices

Z-Index & Stacking Conflicts

  • Positioning wrapper uses absolute/fixed positioning
  • May interfere with existing sticky headers, modals, or dropdowns
  • Adjust z-index values manually if conflicts occur

Performance

  • Multiple Ratio instances on one page = more resize recalculations
  • Can cause jank on low-end devices if too many instances
  • Consider debouncing or limiting instances on performance-critical pages

Planned Enhancements

  • Draggable container for one-handed operation
  • Built-in safe-area inset handling
  • Corner control slots for external buttons
  • Super debug mode (logs edge case warnings + state changes: mode switches, dragging events)

Behavior & Edge Cases

Mode Matching

  • Multiple media query matches are resolved by selecting the last matching mode (modes are evaluated in definition order).

Input Validation

  • Zero or negative dimensions: undefined behavior; ensure width > 0 and height > 0
  • Extreme aspect ratios (greater than 100:1 or less than 1:100): may produce unusable containers; clamp values appropriately
  • Invalid padding: null disables padding, 0 enables 0-width padding; negative values break layout

Container Query Support

  • Browsers without container query support: always uses defaultMode
  • Container too small for ratio: content may overflow; configure innerClass with overflow: auto if needed
  • Nested Ratio components: cqmin units may behave unexpectedly in nested contexts; test thoroughly

Draggable Container

  • Dragging out of viewport: container clamps to bounds; user can always access content
  • Multiple draggable instances: active container brings to front (z-index: 9999)

Content Overflow

  • Content exceeds container: scrolls internally (use overflow: hidden to clip)
  • Fixed-position children: escape container bounds; avoid position: fixed inside

Performance & Edge Cases

  • Rapid mode switching: resize window quickly may trigger multiple switches; handled gracefully
  • Empty content: container still renders as empty box; does not collapse

Testing Checklist

  • Test with zero/negative dimensions
  • Test extreme aspect ratios
  • Test on browsers without container query support
  • Test dragging beyond viewport bounds
  • Test nested Ratio components
  • Test rapid window resize events
  • Test with system font scaling enabled

Naming Rationale

The package is named @mdsv/ratio for these reasons:

  • Concise - Short (5 letters), easy to type
  • Clear - “Ratio” directly describes the package’s core function
  • Memorable - Simple, no ambiguity
  • Accurate - Conveys the purpose (aspect ratio enforcement)

Other names considered but not chosen:

  • contain - More about CSS containment than aspect ratios
  • compact - Doesn’t convey the ratio aspect
  • focus - Too vague, could mean many things
  • aspect - Good, but “ratio” is more commonly used in APIs
  • frame - Implies borders/structure, not the core value