Components
Lens
A magnifying-glass image zoom that follows the pointer. Purely presentational — no Morphos equivalent.
Installation
npx kosmesis add lenspnpm dlx kosmesis add lensyarn dlx kosmesis add lensbunx kosmesis add lensCopy and paste the following code into your project.
import { StatefulComponent } from "@praxisjs/core";import { Component, Prop, Ref, State, type Ref as RefType } from "@praxisjs/decorators";import { cn } from "@/lib/utils";export interface LensProps { src: string; alt?: string; zoom?: number; lensSize?: number; class?: string;}// Lens position/zoom recomputed on every pointer move and written to `style` directly (not// `@State`) since the container can be responsive.@Component()export class Lens extends StatefulComponent { @Prop() src!: string; @Prop() alt = ""; @Prop() zoom = 2; @Prop() lensSize = 150; @Prop() class?: string; @Ref<HTMLDivElement>() containerRef!: RefType<HTMLDivElement>; @Ref<HTMLDivElement>() lensRef!: RefType<HTMLDivElement>; @State() _visible = false; private readonly _handlePointerMove = (event: PointerEvent) => { const container = this.containerRef.current; const lens = this.lensRef.current; if (!container || !lens) return; const rect = container.getBoundingClientRect(); const x = event.clientX - rect.left; const y = event.clientY - rect.top; lens.style.left = `${String(x - this.lensSize / 2)}px`; lens.style.top = `${String(y - this.lensSize / 2)}px`; lens.style.backgroundSize = `${String(rect.width * this.zoom)}px ${String(rect.height * this.zoom)}px`; lens.style.backgroundPosition = `${String(-(x * this.zoom - this.lensSize / 2))}px ${String(-(y * this.zoom - this.lensSize / 2))}px`; }; render() { return ( <div ref={this.containerRef} data-slot="lens" class={cn("relative overflow-hidden", this.class)} onPointerEnter={() => { this._visible = true; }} onPointerLeave={() => { this._visible = false; }} onPointerMove={this._handlePointerMove} > <img src={this.src} alt={this.alt} class="block size-full object-cover" /> <div ref={this.lensRef} data-slot="lens-glass" data-visible={() => (this._visible ? "" : undefined)} class="pointer-events-none absolute rounded-full border-2 border-background bg-no-repeat opacity-0 shadow-lg data-[visible]:opacity-100" style={{ width: `${String(this.lensSize)}px`, height: `${String(this.lensSize)}px`, backgroundImage: `url(${this.src})` }} /> </div> ); }}Install the following dependencies:
npm install @praxisjs/csspnpm add @praxisjs/cssyarn add @praxisjs/cssbun add @praxisjs/cssCopy and paste the following code into your project.
import { StatefulComponent } from "@praxisjs/core";import { cx, Stylesheet, Styled, tokenVars } from "@praxisjs/css";import { Component, Prop, Ref, State, type Ref as RefType } from "@praxisjs/decorators";import { KosmesisTokens } from "@/lib/kosmesis-theme";const t = tokenVars(KosmesisTokens);class LensStyles extends Stylesheet { $container = this.css({ position: "relative", overflow: "hidden" }); $image = this.css({ display: "block", width: "100%", height: "100%", objectFit: "cover" }); $glass = this.css({ pointerEvents: "none", position: "absolute", borderRadius: "9999px", border: `2px solid ${t.background}`, backgroundRepeat: "no-repeat", opacity: 0, boxShadow: "0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)", }).on("&[data-visible]", { opacity: 1 });}export interface LensProps { src: string; alt?: string; zoom?: number; lensSize?: number; class?: string;}// Lens position/zoom recomputed on every pointer move and written to `style` directly (not// `@State`) since the container can be responsive.@Component()export class Lens extends StatefulComponent { @Styled(LensStyles) $s!: LensStyles; @Prop() src!: string; @Prop() alt = ""; @Prop() zoom = 2; @Prop() lensSize = 150; @Prop() class?: string; @Ref<HTMLDivElement>() containerRef!: RefType<HTMLDivElement>; @Ref<HTMLDivElement>() lensRef!: RefType<HTMLDivElement>; @State() _visible = false; private readonly _handlePointerMove = (event: PointerEvent) => { const container = this.containerRef.current; const lens = this.lensRef.current; if (!container || !lens) return; const rect = container.getBoundingClientRect(); const x = event.clientX - rect.left; const y = event.clientY - rect.top; lens.style.left = `${String(x - this.lensSize / 2)}px`; lens.style.top = `${String(y - this.lensSize / 2)}px`; lens.style.backgroundSize = `${String(rect.width * this.zoom)}px ${String(rect.height * this.zoom)}px`; lens.style.backgroundPosition = `${String(-(x * this.zoom - this.lensSize / 2))}px ${String(-(y * this.zoom - this.lensSize / 2))}px`; }; render() { return ( <div ref={this.containerRef} data-slot="lens" class={cx(this.$s.$container, this.class)} onPointerEnter={() => { this._visible = true; }} onPointerLeave={() => { this._visible = false; }} onPointerMove={this._handlePointerMove} > <img src={this.src} alt={this.alt} class={this.$s.$image} /> <div ref={this.lensRef} data-slot="lens-glass" data-visible={() => (this._visible ? "" : undefined)} class={this.$s.$glass} style={{ width: `${String(this.lensSize)}px`, height: `${String(this.lensSize)}px`, backgroundImage: `url(${this.src})` }} /> </div> ); }}Examples
About
Purely presentational — no Morphos equivalent. The lens is the same image as a
background-image, sized to containerSize * zoom and offset so the point under the cursor stays
centered under it — recomputed on every pointer move since the container can be responsive
(written imperatively rather than through @State, same reasoning as Resizable).
Usage
import { Lens } from "@/components/ui/lens";
<Lens class="h-64 w-full rounded-lg" src="/product-photo.jpg" alt="Product" zoom={2} lensSize={150} />Props
| Prop | Type | Default |
|---|---|---|
src | string | — |
alt | string | "" |
zoom | number | 2 |
lensSize | number (px diameter) | 150 |
class | string | — |