@beatsphere/expo-klipy
v0.1.0
Published
Klipy GIF API client + drop-in <KlipyPicker /> for React Native and Expo. Tenor migration in 30 seconds.
Maintainers
Readme
@beatsphere/expo-klipy
Klipy GIF API client + drop-in <KlipyPicker /> for React Native and Expo. Designed as a 30-second migration path off Tenor (which Google is shutting down on June 30, 2026).
- Typed REST client with Zod runtime validation
- Themable picker component — search, trending, attribution, safe-area handling
- Per-platform API key resolution (Klipy issues separate keys for iOS / Android / Web)
- Works with
gifs,stickers,clips, andmemesendpoints
Battle-tested in BeatSphere.
Install
npm install @beatsphere/expo-klipyPeer dependencies (already present in any modern Expo app):
react,react-nativeexpo-imagereact-native-safe-area-context@expo/vector-icons
Get API keys
Register at partner.klipy.com and click Add Platform. Klipy issues a separate key per platform — generate one for iOS and one for Android (and one for Web if applicable).
Test keys allow up to 100 requests/minute for development. Request production access from the same panel when you're ready to ship.
Quick Start — <KlipyPicker />
import React, { useState } from 'react';
import { Button, View } from 'react-native';
import { KlipyPicker, resolvePlatformApiKey, type KlipyItem } from '@beatsphere/expo-klipy';
const apiKey = resolvePlatformApiKey({
ios: process.env.EXPO_PUBLIC_KLIPY_API_KEY_IOS,
android: process.env.EXPO_PUBLIC_KLIPY_API_KEY_ANDROID,
});
export function ChatInput() {
const [pickerOpen, setPickerOpen] = useState(false);
const handleSelect = (item: KlipyItem) => {
// `md.gif.url` is a sensible default for sending in chat (~300×300).
// Use `hd` for higher quality, `sm` for a thumbnail, `mp4` to send video.
const gifUrl = item.file.md.gif.url;
sendChatMessage(gifUrl);
setPickerOpen(false);
};
return (
<View>
<Button title="GIF" onPress={() => setPickerOpen(true)} />
<KlipyPicker
visible={pickerOpen}
onClose={() => setPickerOpen(false)}
onSelect={handleSelect}
apiKey={apiKey}
/>
</View>
);
}That's it. The picker handles search debouncing, trending fallback, loading/empty states, swipe-to-dismiss, and the required Klipy attribution.
Quick Start — REST client only
If you're building a custom UI, use the client functions directly:
import { search, trending, type KlipyItem } from '@beatsphere/expo-klipy';
const trendingPage = await trending({ apiKey: 'your_key', perPage: 24 });
const searchPage = await search({ apiKey: 'your_key', q: 'cats', perPage: 24, page: 1 });
trendingPage.items.forEach((gif: KlipyItem) => {
console.log(gif.title, gif.file.md.gif.url);
});
if (searchPage.hasNext) {
const next = await search({ apiKey: 'your_key', q: 'cats', page: 2 });
}Migrating from Tenor
Tenor's response shape | Klipy equivalent
--- | ---
media_formats.gif.url (full size) | file.md.gif.url
media_formats.tinygif.url (thumbnail) | file.sm.gif.url
media_formats.mediumgif.url | file.hd.gif.url
results[] | data.data[] (or .items from this client)
tenor.googleapis.com/v2/search?q=... | api.klipy.com/api/v1/{KEY}/gifs/search?q=...
tenor.googleapis.com/v2/featured | api.klipy.com/api/v1/{KEY}/gifs/trending
API key as ?key= query param | API key in URL path
API
<KlipyPicker />
Drop-in modal picker. Renders as a pageSheet modal on iOS (~85% height, swipe-to-dismiss) and full-screen on Android.
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| visible | boolean | — | Show / hide |
| onClose | () => void | — | Called on dismissal |
| onSelect | (item: KlipyItem) => void | — | Called when the user picks an item |
| apiKey | string | — | Klipy API key |
| contentType | 'gifs' \| 'stickers' \| 'clips' \| 'memes' | 'gifs' | Content category |
| locale | string | — | xx_XX locale code, e.g. 'us_US' |
| rating | 'g' \| 'pg' \| 'pg-13' \| 'r' | — | Content rating filter |
| perPage | number | 24 | Results per request (8–50) |
| searchPlaceholder | string | 'Search Klipy' | Search input placeholder |
| title | string | 'GIFs' | Header title |
| theme | KlipyPickerTheme | dark | Color overrides |
| style | StyleProp<ViewStyle> | — | Container style |
| titleStyle | StyleProp<TextStyle> | — | Title text style |
| numColumns | number | 2 | Grid columns |
| hideAttribution | boolean | false | Hide "Powered by KLIPY". Klipy's terms require attribution — only set this if you render <KlipyAttribution /> elsewhere. |
<KlipyAttribution />
Standalone attribution mark. Use this if you build a custom picker UI.
<KlipyAttribution textStyle={{ fontFamily: 'Inter-Bold' }} />Functions
| Function | Description |
|----------|-------------|
| search({ apiKey, q, ... }) | Keyword search |
| trending({ apiKey, ... }) | Trending content |
| reportShare({ apiKey, itemId }) | Notify Klipy that a piece of content was shared (improves their trending data — best-effort, silent on failure) |
| resolvePlatformApiKey({ ios, android, web, fallback }) | Return the right key for the active platform |
All client functions resolve to a KlipyResultPage:
{
items: KlipyItem[];
currentPage: number;
perPage: number;
hasNext: boolean;
}KlipyItem shape
{
id: number;
slug: string;
title: string;
type: string;
file: {
hd: { gif, webp?, jpg?, mp4?, webm? }; // 300×300, large
md: { gif, webp?, jpg?, mp4?, webm? }; // 300×300, smaller file
sm: { gif, webp?, jpg?, mp4?, webm? }; // 220×220, thumbnail
xs: { gif, webp?, jpg?, mp4?, webm? }; // 90×90, tiny preview
};
tags?: string[];
blur_preview?: string; // base64 placeholder
}Each format object has url, width, height, size.
Theming
<KlipyPicker
// ...
theme={{
background: '#FFFFFF',
surface: '#F2F2F7',
text: '#000000',
mutedText: '#8E8E93',
divider: '#C6C6C8',
tilePlaceholder: '#E5E5EA',
attributionText: '#999999',
}}
/>Attribution
Klipy's terms require visible attribution wherever their content is displayed. The picker renders a "POWERED BY KLIPY" footer by default. If you build a custom UI, render <KlipyAttribution /> somewhere visible.
License
MIT © BeatSphere
