@vettly/react
v0.1.19
Published
React components for content moderation. ModeratedTextarea, ImageUpload, and VideoUpload with built-in content moderation.
Downloads
37
Maintainers
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/reactQuick 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
- Sign up at vettly.dev
- Go to Dashboard > API Keys
- Create and copy your publishable key (
vettly_live_...) for client-side use
Links
- vettly.dev - Sign up
- docs.vettly.dev - Documentation
- @vettly/sdk - Server-side SDK
