@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
| Prop | Type | Default | Description |
|---|---|---|---|
modes | Record<string, RatioMode> | — | Media query → mode mapping |
defaultMode | RatioMode | {width:1, height:1, padding:0} | Fallback mode |
currentMode | RatioMode (bindable) | — | Currently active mode |
containerType | CSS.Properties['containerType'] | 'size' | Container query type |
outerClass | ClassValue | — | Outer wrapper styles (full viewport/parent container) |
positionClass | ClassValue | — | Styles for positioning wrapper (handles centering + draggable placement) |
innerClass | ClassValue | — | Inner content styles (your content lives here) |
debug | boolean | false | Enable 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
innerClassoverflow 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
paddingfor 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 > 0andheight > 0 - Extreme aspect ratios (greater than 100:1 or less than 1:100): may produce unusable containers; clamp values appropriately
- Invalid padding:
nulldisables padding,0enables 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
innerClasswithoverflow: autoif 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: hiddento clip) - Fixed-position children: escape container bounds; avoid
position: fixedinside
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 ratioscompact- Doesn’t convey the ratio aspectfocus- Too vague, could mean many thingsaspect- Good, but “ratio” is more commonly used in APIsframe- Implies borders/structure, not the core value