Comparison
A slider-based component for comparing two items in an overlay. Purely presentational — no Morphos equivalent.
Installation
npx kosmesis add comparisonpnpm dlx kosmesis add comparisonyarn dlx kosmesis add comparisonbunx kosmesis add comparisonInstall the following dependencies:
npm install @morphos/iconspnpm add @morphos/iconsyarn add @morphos/iconsbun add @morphos/iconsCopy and paste the following code into your project.
import { StatefulComponent, StatelessComponent } from "@praxisjs/core";import { Component, Ref, State, type Ref as RefType } from "@praxisjs/decorators";import type { Children } from "@praxisjs/shared";import { Icon } from "@morphos/icons";import { cn } from "@/lib/utils";export interface ComparisonProps { class?: string; children?: Children;}@Component()export class ComparisonState extends StatefulComponent { @State() position = 50; @Ref<HTMLDivElement>() containerRef!: RefType<HTMLDivElement>; private _dragging = false; private readonly _updateFromClientX = (clientX: number) => { const container = this.containerRef.current; if (!container) return; const rect = container.getBoundingClientRect(); const pct = ((clientX - rect.left) / rect.width) * 100; this.position = Math.min(100, Math.max(0, pct)); }; readonly handlePointerDown = (event: PointerEvent) => { event.preventDefault(); this._dragging = true; (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId); this._updateFromClientX(event.clientX); }; readonly handlePointerMove = (event: PointerEvent) => { if (!this._dragging) return; this._updateFromClientX(event.clientX); }; readonly handlePointerUp = () => { this._dragging = false; }; readonly handleKeyDown = (event: KeyboardEvent) => { const step = event.shiftKey ? 10 : 2; if (event.key === "ArrowLeft") { event.preventDefault(); this.position = Math.max(0, this.position - step); } else if (event.key === "ArrowRight") { event.preventDefault(); this.position = Math.min(100, this.position + step); } else if (event.key === "Home") { event.preventDefault(); this.position = 0; } else if (event.key === "End") { event.preventDefault(); this.position = 100; } }; render() { return null; }}export interface ComparisonComposedProps { comparison: ComparisonState; class?: string; children?: Children;}// Needs an explicit height from the consumer (e.g. `class="aspect-video"` or a fixed height) —// `ComparisonItem`s are absolutely positioned, so they don't themselves establish one.@Component()export class Comparison extends StatelessComponent<ComparisonComposedProps> { render() { const { comparison, class: cls, children } = this.props; return ( <div ref={comparison.containerRef} data-slot="comparison" class={cn("relative isolate select-none overflow-hidden", cls)} > {children} </div> ); }}export interface ComparisonItemProps { comparison: ComparisonState; position: "left" | "right"; class?: string; children?: Children;}@Component()export class ComparisonItem extends StatelessComponent<ComparisonItemProps> { render() { const { comparison, position, class: cls, children } = this.props; return ( <div data-slot="comparison-item" class={cn("absolute inset-0", cls)} style={() => ({ clipPath: position === "left" ? `inset(0 ${String(100 - comparison.position)}% 0 0)` : `inset(0 0 0 ${String(comparison.position)}%)`, })} > {children} </div> ); }}export interface ComparisonHandleProps { comparison: ComparisonState; class?: string; children?: Children;}@Component()export class ComparisonHandle extends StatelessComponent<ComparisonHandleProps> { render() { const { comparison, class: cls, children } = this.props; return ( <div data-slot="comparison-handle" role="slider" aria-label="Comparison position" aria-valuenow={() => Math.round(comparison.position)} aria-valuemin={0} aria-valuemax={100} aria-orientation="horizontal" tabIndex={0} class={cn( "absolute inset-y-0 z-10 flex w-10 -translate-x-1/2 cursor-ew-resize touch-none items-center justify-center outline-none", cls, )} style={() => ({ left: `${String(comparison.position)}%` })} onPointerDown={comparison.handlePointerDown} onPointerMove={comparison.handlePointerMove} onPointerUp={comparison.handlePointerUp} onKeyDown={comparison.handleKeyDown} > <div class="pointer-events-none absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-background" /> {children ?? ( <div class="flex size-8 items-center justify-center rounded-full border bg-background shadow-xs"> <Icon name="ChevronsLeftRight" size={16} /> </div> )} </div> ); }}Install the following dependencies:
npm install @morphos/icons @praxisjs/csspnpm add @morphos/icons @praxisjs/cssyarn add @morphos/icons @praxisjs/cssbun add @morphos/icons @praxisjs/cssCopy and paste the following code into your project.
import { StatefulComponent, StatelessComponent } from "@praxisjs/core";import { cx, Stylesheet, Styled, tokenVars } from "@praxisjs/css";import { Component, Ref, State, type Ref as RefType } from "@praxisjs/decorators";import type { Children } from "@praxisjs/shared";import { Icon } from "@morphos/icons";import { KosmesisTokens } from "@/lib/kosmesis-theme";const t = tokenVars(KosmesisTokens);export interface ComparisonProps { class?: string; children?: Children;}@Component()export class ComparisonState extends StatefulComponent { @State() position = 50; @Ref<HTMLDivElement>() containerRef!: RefType<HTMLDivElement>; private _dragging = false; private readonly _updateFromClientX = (clientX: number) => { const container = this.containerRef.current; if (!container) return; const rect = container.getBoundingClientRect(); const pct = ((clientX - rect.left) / rect.width) * 100; this.position = Math.min(100, Math.max(0, pct)); }; readonly handlePointerDown = (event: PointerEvent) => { event.preventDefault(); this._dragging = true; (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId); this._updateFromClientX(event.clientX); }; readonly handlePointerMove = (event: PointerEvent) => { if (!this._dragging) return; this._updateFromClientX(event.clientX); }; readonly handlePointerUp = () => { this._dragging = false; }; readonly handleKeyDown = (event: KeyboardEvent) => { const step = event.shiftKey ? 10 : 2; if (event.key === "ArrowLeft") { event.preventDefault(); this.position = Math.max(0, this.position - step); } else if (event.key === "ArrowRight") { event.preventDefault(); this.position = Math.min(100, this.position + step); } else if (event.key === "Home") { event.preventDefault(); this.position = 0; } else if (event.key === "End") { event.preventDefault(); this.position = 100; } }; render() { return null; }}class ComparisonStyles extends Stylesheet { $root = this.css({ position: "relative", isolation: "isolate", userSelect: "none", overflow: "hidden", width: "100%", height: "100%", }); $item = this.css({ position: "absolute", inset: "0" }); $handle = this.css({ position: "absolute", insetBlock: "0", zIndex: 10, display: "flex", width: "2.5rem", transform: "translateX(-50%)", alignItems: "center", justifyContent: "center", cursor: "ew-resize", touchAction: "none", outline: "none", }); $handleLine = this.css({ pointerEvents: "none", position: "absolute", insetBlock: "0", left: "50%", width: "1px", transform: "translateX(-50%)", backgroundColor: t.background, }); $handleGrip = this.css({ display: "flex", width: "2rem", height: "2rem", alignItems: "center", justifyContent: "center", borderRadius: "9999px", border: `1px solid ${t.border}`, backgroundColor: t.background, boxShadow: "0 1px 2px 0 rgb(0 0 0 / 0.05)", });}export interface ComparisonComposedProps { comparison: ComparisonState; class?: string; children?: Children;}// Needs an explicit height from the consumer (e.g. `aspect-ratio` or a fixed height) —// `ComparisonItem`s are absolutely positioned, so they don't themselves establish one.@Component()export class Comparison extends StatelessComponent<ComparisonComposedProps> { @Styled(ComparisonStyles) $s!: ComparisonStyles; render() { const { comparison, class: cls, children } = this.props; return ( <div ref={comparison.containerRef} data-slot="comparison" class={cx(this.$s.$root, cls)}> {children} </div> ); }}export interface ComparisonItemProps { comparison: ComparisonState; position: "left" | "right"; class?: string; children?: Children;}@Component()export class ComparisonItem extends StatelessComponent<ComparisonItemProps> { @Styled(ComparisonStyles) $s!: ComparisonStyles; render() { const { comparison, position, class: cls, children } = this.props; return ( <div data-slot="comparison-item" class={cx(this.$s.$item, cls)} style={() => ({ clipPath: position === "left" ? `inset(0 ${String(100 - comparison.position)}% 0 0)` : `inset(0 0 0 ${String(comparison.position)}%)`, })} > {children} </div> ); }}export interface ComparisonHandleProps { comparison: ComparisonState; class?: string; children?: Children;}@Component()export class ComparisonHandle extends StatelessComponent<ComparisonHandleProps> { @Styled(ComparisonStyles) $s!: ComparisonStyles; render() { const { comparison, class: cls, children } = this.props; return ( <div data-slot="comparison-handle" role="slider" aria-label="Comparison position" aria-valuenow={() => Math.round(comparison.position)} aria-valuemin={0} aria-valuemax={100} aria-orientation="horizontal" tabIndex={0} class={cx(this.$s.$handle, cls)} style={() => ({ left: `${String(comparison.position)}%` })} onPointerDown={comparison.handlePointerDown} onPointerMove={comparison.handlePointerMove} onPointerUp={comparison.handlePointerUp} onKeyDown={comparison.handleKeyDown} > <div class={this.$s.$handleLine} /> {children ?? ( <div class={this.$s.$handleGrip}> <Icon name="ChevronsLeftRight" size={16} /> </div> )} </div> ); }}Examples
About
Purely presentational — no Morphos equivalent. ComparisonState follows the same split as
CarouselState: it's a pure state container (render() returns null), instantiated directly by
the consumer and passed to Comparison/ComparisonItem/ComparisonHandle via the comparison
prop, rather than something Comparison creates and owns internally. position (0-100) is a
percentage clipping each ComparisonItem with clip-path: inset(...) — the "left" item is
clipped to 0-position%, the "right" item to position-100%, so they tile without needing
z-index stacking. Dragging the handle (pointer or ArrowLeft/ArrowRight/Home/End once
focused) updates position by reading the pointer's X position relative to Comparison's own
bounding rect on every move.
Comparison itself needs an explicit size from the consumer — ComparisonItems are absolutely
positioned, so they never establish one on their own. Give it a fixed height or an aspect-ratio
class (e.g. Tailwind's aspect-video, or aspect-ratio in @praxisjs/css).
Usage
@Component()
class ImageCompare extends StatefulComponent {
@State() comparison = new ComparisonState();
render() {
return (
<Comparison comparison={this.comparison} class="aspect-video rounded-lg border">
<ComparisonItem comparison={this.comparison} position="left">
<img src="/before.jpg" class="size-full object-cover" alt="Before" />
</ComparisonItem>
<ComparisonItem comparison={this.comparison} position="right">
<img src="/after.jpg" class="size-full object-cover" alt="After" />
</ComparisonItem>
<ComparisonHandle comparison={this.comparison} />
</Comparison>
);
}
}Custom Handle
ComparisonHandle accepts children, replacing the default grip-in-a-circle visual entirely —
the draggable/focusable behavior stays the same either way.
<ComparisonHandle comparison={this.comparison}>
<div class="flex h-9 items-center justify-center rounded-full bg-background px-3 text-xs font-semibold shadow-xs">
⇔
</div>
</ComparisonHandle>Props
ComparisonState
A plain class, not a component prop set — construct it with new ComparisonState() and read/set
comparison.position (0-100) directly if you need to set a starting position other than 50.
Comparison / ComparisonItem / ComparisonHandle
| Prop | Type | Default |
|---|---|---|
comparison | ComparisonState | — |
position | "left" | "right" (ComparisonItem only) | — |
class | string | — |
ComparisonHandle also accepts children, overriding its default grip visual.