Kosmesis
Components

Avatar Upload

A circular image picker with live preview, driven by FileSelection and FilePreview.

Installation

npx kosmesis add avatar-upload
pnpm dlx kosmesis add avatar-upload
yarn dlx kosmesis add avatar-upload
bunx kosmesis add avatar-upload

Install the following dependencies:

npm install @praxisjs/composables @morphos/icons
pnpm add @praxisjs/composables @morphos/icons
yarn add @praxisjs/composables @morphos/icons
bun add @praxisjs/composables @morphos/icons

This component builds on other Kosmesis components — install these first (via kosmesis add or this same manual process on their own pages): button.

Copy and paste the following code into your project.

avatar-upload.tsx
import { FilePreview, type FileSelection } from "@praxisjs/composables";import { StatefulComponent } from "@praxisjs/core";import { Component, Compose, getter, Prop } from "@praxisjs/decorators";import { Icon } from "@morphos/icons";import { Button } from "./button";import { cn } from "@/lib/utils";export interface AvatarUploadProps {  /** Should be configured with `accept: "image/*"` and a single file (the default). */  selection: FileSelection;  label?: string;  class?: string;}@Component()export class AvatarUpload extends StatefulComponent {  @Prop() selection!: FileSelection;  @Prop() label = "Upload photo";  @Prop() class?: string;  get file(): File | null {    return this.selection.files[0] ?? null;  }  @Compose(FilePreview, getter("file"))  preview!: FilePreview;  render() {    return (      <div class={cn("flex items-center gap-4", this.class)}>        <span class="flex size-16 shrink-0 items-center justify-center overflow-hidden rounded-full border bg-muted text-muted-foreground">          {() =>            this.preview.url ? (              <img src={this.preview.url} alt="" class="size-full object-cover" />            ) : (              <Icon name="User" size={24} />            )          }        </span>        <div class="flex flex-col items-start gap-1">          <div class="flex gap-2">            <Button variant="outline" size="sm" onClick={() => { this.selection.open(); }}>              {() => (this.file ? "Change" : this.label)}            </Button>            {() =>              this.file && (                <Button variant="ghost" size="sm" onClick={() => { this.selection.clear(); }}>                  Remove                </Button>              )            }          </div>          {() =>            this.selection.errors.length > 0 && (              <p role="alert" class="text-xs text-destructive">{this.selection.errors[0].message}</p>            )          }        </div>      </div>    );  }}

Install the following dependencies:

npm install @praxisjs/composables @morphos/icons @praxisjs/css
pnpm add @praxisjs/composables @morphos/icons @praxisjs/css
yarn add @praxisjs/composables @morphos/icons @praxisjs/css
bun add @praxisjs/composables @morphos/icons @praxisjs/css

This component builds on other Kosmesis components — install these first (via kosmesis add or this same manual process on their own pages): button.

Copy and paste the following code into your project.

avatar-upload.tsx
import { FilePreview, type FileSelection } from "@praxisjs/composables";import { StatefulComponent } from "@praxisjs/core";import { cx, Stylesheet, Styled, tokenVars } from "@praxisjs/css";import { Component, Compose, getter, Prop } from "@praxisjs/decorators";import { Icon } from "@morphos/icons";import { Button } from "./button";import { KosmesisTokens } from "@/lib/kosmesis-theme";const t = tokenVars(KosmesisTokens);class AvatarUploadStyles extends Stylesheet {  $root = this.css({ display: "flex", alignItems: "center", gap: "1rem" });  $preview = this.css({    display: "flex",    width: "4rem",    height: "4rem",    flexShrink: 0,    alignItems: "center",    justifyContent: "center",    overflow: "hidden",    borderRadius: "9999px",    border: `1px solid ${t.border}`,    backgroundColor: t.muted,    color: t.mutedForeground,  });  $image = this.css({ width: "100%", height: "100%", objectFit: "cover" });  $controls = this.css({ display: "flex", flexDirection: "column", alignItems: "flex-start", gap: "0.25rem" });  $actions = this.css({ display: "flex", gap: "0.5rem" });  $error = this.css({ margin: 0, fontSize: "0.75rem", color: t.destructive });}export interface AvatarUploadProps {  /** Should be configured with `accept: "image/*"` and a single file (the default). */  selection: FileSelection;  label?: string;  class?: string;}@Component()export class AvatarUpload extends StatefulComponent {  @Styled(AvatarUploadStyles) $s!: AvatarUploadStyles;  @Prop() selection!: FileSelection;  @Prop() label = "Upload photo";  @Prop() class?: string;  get file(): File | null {    return this.selection.files[0] ?? null;  }  @Compose(FilePreview, getter("file"))  preview!: FilePreview;  render() {    return (      <div class={cx(this.$s.$root, this.class)}>        <span class={this.$s.$preview}>          {() =>            this.preview.url ? (              <img src={this.preview.url} alt="" class={this.$s.$image} />            ) : (              <Icon name="User" size={24} />            )          }        </span>        <div class={this.$s.$controls}>          <div class={this.$s.$actions}>            <Button variant="outline" size="sm" onClick={() => { this.selection.open(); }}>              {() => (this.file ? "Change" : this.label)}            </Button>            {() =>              this.file && (                <Button variant="ghost" size="sm" onClick={() => { this.selection.clear(); }}>                  Remove                </Button>              )            }          </div>          {() =>            this.selection.errors.length > 0 && (              <p role="alert" class={this.$s.$error}>{this.selection.errors[0].message}</p>            )          }        </div>      </div>    );  }}

Examples

Usage

These components are presentation for the file upload composables in @praxisjs/composables (installed automatically by the CLI). The composable is attached with @Compose in your component, so validation options (accept, maxSize, maxFiles, …) stay yours, and is passed to the Kosmesis component as a prop.

import { FileSelection } from "@praxisjs/composables";
import { AvatarUpload } from "@/components/ui/avatar-upload";

@Component()
class Profile extends StatefulComponent {
  @Compose(FileSelection, { accept: "image/*", maxSize: 2 * 1024 * 1024 })
  selection!: FileSelection;

  render() {
    return <AvatarUpload selection={this.selection} />;
  }
}

Uses a single-file selection (the FileSelection default). The preview is a FilePreview composed inside the component, so its object URL is cleaned up automatically.

Props

PropTypeDefault
selectionFileSelection— required
labelstring"Upload photo"
classstring—

On this page