npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@vettly/react

v0.1.19

Published

React components for content moderation. ModeratedTextarea, ImageUpload, and VideoUpload with built-in content moderation.

Downloads

37

Readme

@vettly/react

React components for UGC moderation. Real-time policy feedback, decision tracking, and moderated uploads.

UGC Moderation Essentials

Apps with user-generated content need four things to stay compliant and keep users safe. This package handles the client-side UX for content filtering and audit trails — pair with @vettly/sdk on the server for reporting and blocking:

| Requirement | React Integration | |-------------|-------------------| | Content filtering | <ModeratedTextarea>, <ModeratedImageUpload>, <ModeratedVideoUpload> | | User reporting | Pair with server-side SDK (POST /v1/reports) | | User blocking | Pair with server-side SDK (POST /v1/blocks) | | Audit trail | Every check returns decisionId — store it with your content |

React Native / Expo

import { useModeration } from '@vettly/react'

function CommentInput() {
  const { result, check } = useModeration({
    apiKey: 'vettly_live_...',
    policyId: 'app-store',
  })

  return (
    <TextInput
      onChangeText={(text) => check(text)}
      style={{
        borderColor: result.action === 'block' ? 'red' : 'green'
      }}
    />
  )
}

Note: Client-side moderation improves UX but is not a security boundary. Always validate on the server before persisting content.

Why Client-Side Moderation?

Immediate feedback - Users see policy violations as they type, not after submission.

Same policies - The React SDK uses your Vettly policies, ensuring consistent enforcement between client preview and server validation.

Decision tracking - Every client-side check returns a decisionId for your audit trail, just like server-side calls.

Always validate server-side - Client-side moderation improves UX but is not a security boundary. Always validate on the server before persisting content.

Installation

npm install @vettly/react

Quick Start

import { ModeratedTextarea } from '@vettly/react'
import '@vettly/react/styles.css'

function CommentForm() {
  const handleSubmit = async (content: string, decisionId: string) => {
    // Submit to your API with the decision ID for audit trail
    await api.createComment({
      content,
      moderationDecisionId: decisionId
    })
  }

  return (
    <ModeratedTextarea
      apiKey={process.env.NEXT_PUBLIC_VETTLY_API_KEY}
      policyId="community-safe"
      placeholder="Write a comment..."
      onModerationResult={(result) => {
        console.log(`Decision: ${result.action} (${result.decisionId})`)
      }}
    />
  )
}

Components

ModeratedTextarea

A textarea with real-time content moderation and visual feedback.

<ModeratedTextarea
  // Required
  apiKey="vettly_live_..."
  policyId="community-safe"

  // Content
  value={content}
  onChange={(value, result) => {
    setContent(value)
    console.log(`Action: ${result.action}`)
  }}
  placeholder="Type something..."

  // Behavior
  debounceMs={500}        // Delay before checking (default: 500ms)
  blockUnsafe={false}     // Prevent typing when content is unsafe
  useFast={true}          // Use fast endpoint (<100ms responses)
  disabled={false}

  // Feedback
  showFeedback={true}     // Show built-in feedback UI
  customFeedback={(result) => (
    <MyCustomFeedback
      action={result.action}
      isChecking={result.isChecking}
      error={result.error}
    />
  )}

  // Callbacks
  onModerationResult={(result) => {
    // Full CheckResponse with decisionId
    console.log(result.decisionId)
  }}
  onModerationError={(error) => {
    console.error('Moderation failed:', error)
  }}

  // Standard textarea props
  className="my-textarea"
  rows={4}
  maxLength={1000}
/>

Props

| Prop | Type | Default | Description | |------|------|---------|-------------| | apiKey | string | required | Your Vettly API key | | policyId | string | required | Policy ID to apply | | value | string | '' | Controlled value | | onChange | (value, result) => void | - | Called on value change with current moderation state | | debounceMs | number | 500 | Milliseconds to wait before checking | | showFeedback | boolean | true | Show built-in feedback component | | blockUnsafe | boolean | false | Prevent additional input when content is unsafe | | useFast | boolean | false | Use fast endpoint for sub-100ms responses | | customFeedback | (result) => ReactNode | - | Custom feedback component | | onModerationResult | (result) => void | - | Called with full moderation response | | onModerationError | (error) => void | - | Called on moderation errors |


ModeratedImageUpload

Image upload component with pre-upload moderation.

<ModeratedImageUpload
  // Required
  apiKey="vettly_live_..."
  policyId="strict"

  // Callbacks
  onUpload={(file, result) => {
    if (result.action !== 'block') {
      uploadToServer(file)
    }
    console.log(`Decision: ${result.action}`)
  }}
  onReject={(file, reason) => {
    console.log(`Rejected: ${reason}`)
  }}

  // Constraints
  maxSizeMB={10}
  acceptedFormats={['image/jpeg', 'image/png', 'image/gif', 'image/webp']}

  // Behavior
  showPreview={true}
  blockUnsafe={true}      // Prevent upload if content violates policy
  disabled={false}

  // Custom preview
  customPreview={({ file, preview, result, onRemove }) => (
    <div>
      <img src={preview} alt={file.name} />
      <p>{result.action}</p>
      <button onClick={onRemove}>Remove</button>
    </div>
  )}

  // Callbacks
  onModerationResult={(result) => {
    console.log(`Image decision: ${result.decisionId}`)
  }}
  onModerationError={(error) => {
    console.error('Image moderation failed:', error)
  }}

  className="my-upload"
/>

Props

| Prop | Type | Default | Description | |------|------|---------|-------------| | apiKey | string | required | Your Vettly API key | | policyId | string | required | Policy ID to apply | | onUpload | (file, result) => void | - | Called when image passes moderation and user confirms | | onReject | (file, reason) => void | - | Called when image is rejected (validation or policy) | | maxSizeMB | number | 10 | Maximum file size in MB | | acceptedFormats | string[] | ['image/jpeg', 'image/png', 'image/gif', 'image/webp'] | Accepted MIME types | | showPreview | boolean | true | Show image preview after selection | | blockUnsafe | boolean | true | Prevent upload if content violates policy | | customPreview | (props) => ReactNode | - | Custom preview component | | onModerationResult | (result) => void | - | Called with full moderation response | | onModerationError | (error) => void | - | Called on moderation errors |


ModeratedVideoUpload

Video upload with frame extraction and moderation.

<ModeratedVideoUpload
  // Required
  apiKey="vettly_live_..."
  policyId="video-policy"

  // Callbacks
  onUpload={(file, result) => {
    uploadVideoToServer(file)
    console.log(`Video approved: ${result.action}`)
  }}
  onReject={(file, reason) => {
    console.log(`Video rejected: ${reason}`)
  }}

  // Constraints
  maxSizeMB={100}
  maxDurationSeconds={300}  // 5 minutes
  acceptedFormats={['video/mp4', 'video/webm', 'video/quicktime']}

  // Frame extraction
  extractFramesCount={3}    // Number of frames to extract for analysis

  // Behavior
  showPreview={true}
  blockUnsafe={true}
  disabled={false}

  // Custom preview
  customPreview={({ file, preview, duration, result, onRemove }) => (
    <div>
      <video src={preview} controls />
      <p>Duration: {duration}s</p>
      <p>Status: {result.action}</p>
      <button onClick={onRemove}>Remove</button>
    </div>
  )}

  // Callbacks
  onModerationResult={(result) => {
    console.log(`Video decision: ${result.decisionId}`)
  }}
  onModerationError={(error) => {
    console.error('Video moderation failed:', error)
  }}

  className="my-video-upload"
/>

Props

| Prop | Type | Default | Description | |------|------|---------|-------------| | apiKey | string | required | Your Vettly API key | | policyId | string | required | Policy ID to apply | | onUpload | (file, result) => void | - | Called when video passes moderation | | onReject | (file, reason) => void | - | Called when video is rejected | | maxSizeMB | number | 100 | Maximum file size in MB | | maxDurationSeconds | number | 300 | Maximum video duration in seconds | | acceptedFormats | string[] | ['video/mp4', 'video/webm', 'video/quicktime'] | Accepted MIME types | | extractFramesCount | number | 3 | Number of frames to extract for moderation | | showPreview | boolean | true | Show video preview with thumbnail | | blockUnsafe | boolean | true | Prevent upload if content violates policy | | customPreview | (props) => ReactNode | - | Custom preview component | | onModerationResult | (result) => void | - | Called with full moderation response | | onModerationError | (error) => void | - | Called on moderation errors |

Features

  • Drag and drop - Drop videos directly onto the upload area
  • Thumbnail generation - Automatically generates video thumbnail
  • Progress tracking - Shows frame extraction and analysis progress
  • Duration validation - Rejects videos exceeding maximum duration

useModeration Hook

For custom components, use the useModeration hook directly:

import { useModeration } from '@vettly/react'

function CustomModerationUI() {
  const { result, check } = useModeration({
    apiKey: 'vettly_live_...',
    policyId: 'community-safe',
    debounceMs: 500,
    enabled: true,
    useFast: false,
    onCheck: (response) => {
      console.log(`Decision: ${response.decisionId}`)
    },
    onError: (error) => {
      console.error('Check failed:', error)
    }
  })

  return (
    <div>
      <textarea
        onChange={(e) => check(e.target.value)}
        style={{
          borderColor: result.action === 'block' ? 'red' :
                       result.action === 'flag' ? 'yellow' :
                       result.action === 'warn' ? 'orange' : 'green'
        }}
      />

      {result.isChecking && <span>Checking...</span>}

      {result.error && <span className="error">{result.error}</span>}

      {!result.isChecking && !result.error && (
        <div>
          <strong>Action:</strong> {result.action}
          <ul>
            {result.categories
              .filter(c => c.triggered)
              .map(c => (
                <li key={c.category}>
                  {c.category}: {(c.score * 100).toFixed(0)}%
                </li>
              ))
            }
          </ul>
        </div>
      )}
    </div>
  )
}

Options

| Option | Type | Default | Description | |--------|------|---------|-------------| | apiKey | string | required | Your Vettly API key | | policyId | string | required | Policy ID to apply | | debounceMs | number | 500 | Milliseconds to debounce checks | | enabled | boolean | true | Enable/disable moderation | | useFast | boolean | false | Use fast endpoint for sub-100ms responses | | onCheck | (response) => void | - | Called with full CheckResponse | | onError | (error) => void | - | Called on errors |

Return Value

interface UseModerationReturn {
  result: {
    safe: boolean           // True if content passes policy
    flagged: boolean        // True if content is flagged for review
    action: 'allow' | 'warn' | 'flag' | 'block'
    categories: Array<{
      category: string
      score: number         // 0-1 confidence score
      triggered: boolean    // True if threshold exceeded
    }>
    isChecking: boolean     // True while check is in progress
    error: string | null    // Error message if check failed
  }
  check: (content: string | CheckRequest) => Promise<void>
}

Advanced Usage: Full Request Object

const { check } = useModeration({ apiKey, policyId })

// Check with full request object
check({
  content: imageBase64,
  contentType: 'image',
  policyId: 'strict-images',
  metadata: {
    userId: 'user_123',
    context: 'profile_photo'
  }
})

Styling & Customization

Default Styles

Import the default stylesheet:

import '@vettly/react/styles.css'

CSS Classes

All components use semantic CSS classes for easy customization:

/* Textarea wrapper */
.moderated-textarea-wrapper { }
.moderated-textarea { }

/* Feedback states */
.moderation-feedback { }
.feedback-checking { }
.feedback-safe { }
.feedback-warn { }
.feedback-flag { }
.feedback-block { }
.feedback-error { }

/* Image upload */
.moderated-image-upload { }
.upload-area { }
.upload-icon { }
.upload-text { }
.upload-hint { }
.upload-error { }
.image-preview { }
.preview-container { }
.preview-image { }
.preview-overlay { }
.preview-info { }
.moderation-status { }
.status-allow { }
.status-warn { }
.status-flag { }
.status-block { }
.preview-actions { }
.btn-remove { }
.btn-confirm { }

/* Video upload */
.moderated-video-upload { }
.drag-over { }
.thumbnail-container { }
.video-thumbnail { }
.play-overlay { }
.play-button { }
.duration-badge { }
.processing-overlay { }
.progress-bar { }
.progress-fill { }

Border Colors by Action

The default styles use border colors to indicate moderation status:

| Action | Border Color | |--------|--------------| | Checking | Blue (border-blue-300) | | Allow | Green (border-green-400) | | Warn | Orange (border-orange-400) | | Flag | Yellow (border-yellow-400) | | Block | Red (border-red-400) | | Error | Red (border-red-400) |

Custom Feedback Component

<ModeratedTextarea
  apiKey={apiKey}
  policyId={policyId}
  showFeedback={false}  // Disable default feedback
  customFeedback={(result) => (
    <div className="my-feedback">
      {result.isChecking ? (
        <Spinner />
      ) : result.error ? (
        <ErrorBanner message={result.error} />
      ) : result.action === 'block' ? (
        <BlockedMessage categories={result.categories} />
      ) : (
        <SafeIndicator />
      )}
    </div>
  )}
/>

Accessibility

All components follow accessibility best practices:

Keyboard Navigation

  • Tab - Navigate between components
  • Enter/Space - Activate upload buttons
  • Escape - Cancel previews (when implemented)

ARIA Attributes

  • Upload areas have role="button" and proper focus handling
  • Error messages use appropriate ARIA live regions
  • Progress indicators announce status changes

Screen Reader Support

  • Feedback messages are announced as they change
  • Upload areas have descriptive labels
  • Error states provide clear explanations

Server-Side Validation

Client-side moderation is for user experience only. Always validate on the server:

// Client-side (React)
function CommentForm() {
  const [content, setContent] = useState('')
  const [clientResult, setClientResult] = useState(null)

  return (
    <form onSubmit={async (e) => {
      e.preventDefault()

      // Server validates again - don't trust client-side result
      const response = await fetch('/api/comments', {
        method: 'POST',
        body: JSON.stringify({
          content,
          clientDecisionId: clientResult?.decisionId  // Optional: for correlation
        })
      })

      if (response.ok) {
        // Success
      } else if (response.status === 403) {
        // Server blocked content
        const error = await response.json()
        showError(error.message)
      }
    }}>
      <ModeratedTextarea
        apiKey={process.env.NEXT_PUBLIC_VETTLY_API_KEY}
        policyId="community-safe"
        value={content}
        onChange={setContent}
        onModerationResult={setClientResult}
      />
      <button type="submit">Post</button>
    </form>
  )
}

// Server-side (API route)
import { createClient } from '@vettly/sdk'

const vettly = createClient(process.env.VETTLY_API_KEY)

export async function POST(req) {
  const { content, clientDecisionId } = await req.json()

  // Always validate server-side
  const result = await vettly.check({
    content,
    policyId: 'community-safe',
    metadata: { clientDecisionId }  // Optional: for correlation
  })

  if (result.action === 'block') {
    return Response.json(
      { error: 'Content blocked', decisionId: result.decisionId },
      { status: 403 }
    )
  }

  // Store with decision ID for audit trail
  await db.comments.create({
    content,
    moderationDecisionId: result.decisionId,
    action: result.action
  })

  return Response.json({ success: true })
}

Get Your API Key

  1. Sign up at vettly.dev
  2. Go to Dashboard > API Keys
  3. Create and copy your publishable key (vettly_live_...) for client-side use

Links