Expokit
AboutDocsComponentsChartsBlocksPricing
Sign inGet started
Overview3D ButtonCard StackProCube PagerProFlip CardGift CardProLoyalty CardProParallax LayersProParallax ScrollProPayment CardRing CarouselProSwipe DeckProTicketProTilt CardWheel ListPro
3D Button
Sponsor ExpoKit

Free stays free. Tip once — keep shipping.

Support
About
Docs
IntroductionWhat ExpoKit is and how it worksInstallationAdd ExpoKit to an Expo appThemingColours, radius and dark modeCLIinit, add, login and updateAI assistants (MCP)Cursor, Claude, Antigravity and moreRun on your phoneTry components in Expo GoChangelogWhat shipped recently
npx @expokit/cli@latest initQuick start
Components
Liquid Glass8 iOS 26 glass pieces3DNew14 tilt, flip and stack
Inputs 12
  • CalendarNew
  • Date PickerNew
  • Floating Input
  • OTP Input
  • Password Input
  • +7 more
Selection 7
  • Checkbox
  • Chip Group
  • Color SwatchPro
  • Radio Group
  • RatingPro
  • +2 more
Buttons 7
  • Button
  • Floating Action ButtonPro
  • Like ButtonPro
  • Morph ButtonPro
  • Shimmer ButtonPro
  • +2 more
Feedback 5
  • Alert
  • Progress
  • Skeleton
  • Spinner
  • Toast
Overlays 4
  • Action SheetNew
  • Bottom Sheet
  • Dialog
  • DrawerNew
Display 9
  • Accordion
  • Avatar
  • Badge
  • Card
  • CarouselPro
  • +4 more
Navigation 3
  • Bottom NavNew
  • Progress StepsNew
  • Tabs
Motion 2
  • Number TickerPro
  • Text Animation
70 components · 347 live variantsAll components
Charts

Cartesian

  • Area Chart
  • Bar Chart
  • Line Chart
  • Combo Chart

Circular

  • Pie & Donut
  • Radial Chart
  • Radar Chart
  • Polar AreaPro

3D & Pixel

  • Isometric BarsPro
  • Pixel BarsPro

Specialty

  • TreemapPro
  • WaterfallPro
  • BulletPro
  • TimelinePro
  • Gauge
  • Candlestick
  • Heatmap
  • Funnel

Classic

  • Sparkline
  • Progress Rings
  • MultiLine (classic)
  • StackedBar (classic)
  • Ranking Bars (classic)

Guides

  • Usage & config
  • Patterns & effects
  • Show / hide anything
  • Chart theming
  • Interactions
23 charts, built on the open-source expo-charts engineAll charts
Blocks

Screens

ChatProAn AI assistant chat screenCheckoutProA premium checkoutDashboardProA finance / analytics home screenInboxProA messaging inboxOnboardingProA minimal swipeable welcome flowProfileProAccount profile for any appSearch ResultsProA clean, monochrome search screenSettingsProAn iOS-style grouped settings screenSign InProOne configurable sign-in flow modelled on top mobile apps
9 complete screens — drop one in, wire your dataSee them on devices All blocks
Pricing
Docs3D3D Button

3D Button

Scan with Expo Go

3D Button with all its variants, native in Expo Go. No account needed.

No Expo Go? Open in your browser

A keycap-style button whose lit face sinks into its edge when pressed.

<Button3D />5 variantsiOS · Android · Expo Go
9:41

Sunday 18 October

9:41

Installation#

One command copies the source into your app — you own the code and can change anything.

Usage#

Colours, radius and light / dark come from useKit(). Wrap your app in <KitProvider accent="#6366f1" radius={14}> to re-skin every component at once.

Props#

PropTypeDefault
children

Label, a single glyph (circle buttons) or custom content.

ReactNode—
onPress

Fires on tap.

() => void—
appearance

Keycap material; auto is light on dark screens and dark on light ones. Glass is for buttons on photos.

"auto" | "light" | "dark" | "glass""auto"
color

A custom face colour instead of a material, e.g. your brand colour.

string—
size

Height and text size (36 / 46 / 56).

"sm" | "md" | "lg""md"
shape

Keycap, pill or round icon button.

"rounded" | "pill" | "circle""rounded"
depth

Edge height in points.

number4 / 5 / 6 by size
icon

Shown before the label.

ReactNode—
block

Stretch to the parent's width.

booleanfalse
loading

Show a spinner and ignore taps.

booleanfalse
disabled

Dim the button and ignore taps.

booleanfalse
haptics

Light haptic tap on press (iOS / Android).

booleantrue
accessibilityLabel

Label for screen readers (needed for icon-only buttons).

string—
style

Style for the root (width, margins).

StyleProp<ViewStyle>—

Behaviour & accessibility#

Touch
The face sinks into its edge on press-in and springs back on release. Small sizes pad their hit area to 44pt.
Haptics
A light impact on press-in on iOS and on press on Android, so scrolling past it doesn't buzz. Turn off with haptics={false}.
Screen readers
A button with disabled and busy state. Pass accessibilityLabel for circle keys that show only a glyph.

Source#

PreviousText AnimationNextCard Stack

Did you like the content?

Coding agents

Chat apps

Other

9:41

On Photo

3D Button

Manual installation
  1. Install dependencies

    terminal
    npx expo install react-native-svg expo-haptics
  2. Copy the files

    Create each file at the same path and paste its code. @/ points at your src/ folder (the default in new Expo apps). Skip any file you already have, like lib/kit.tsx.

    • src/components/ui/button-3d.tsx213 lines
    • src/lib/kit.tsx220 lines

    Want to read it first? Every file is in Source below.

  3. Use it

    App.tsx
    import { Button3D } from "@/components/ui/button-3d";

    See Usage for a full example.

Example.tsx
import { Button3D } from '@/components/ui/button-3d'; <Button3D size="lg" shape="pill" onPress={startTrip}>  Start the trip</Button3D>

Every variant above has a complete, runnable example in its Code tab.

src/components/ui/button-3d.tsx
import React, { useRef } from 'react';import { ActivityIndicator, Animated, Platform, Pressable, StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native';import * as Haptics from 'expo-haptics';import Svg, { Defs, LinearGradient, Rect, Stop } from 'react-native-svg'; import { isTextNode, motion, readableOn, useKit } from '@/lib/kit'; export type Button3DAppearance = 'auto' | 'light' | 'dark' | 'glass';export type Button3DSize = 'sm' | 'md' | 'lg';export type Button3DShape = 'rounded' | 'pill' | 'circle'; export interface Button3DProps {  /** Label, a single glyph (circle buttons) or custom content. */  children?: React.ReactNode;  onPress?: () => void;  /**   * Keycap material: `light` (white), `dark` (graphite), `glass` (frosted, for photos) or `auto`   * — light on dark screens, dark on light ones (default).   */  appearance?: Button3DAppearance;  /** A custom face colour instead of a material, e.g. your brand colour. */  color?: string;  size?: Button3DSize;  /** `rounded` keycap, `pill` or `circle`. */  shape?: Button3DShape;  /** Edge height in points (default by size: 4 / 5 / 6). */  depth?: number;  /** Shown before the label. */  icon?: React.ReactNode;  /** Stretch to the parent's width. */  block?: boolean;  /** Swap the label for a spinner and ignore taps. */  loading?: boolean;  disabled?: boolean;  /** Light haptic tap on press (default true). */  haptics?: boolean;  accessibilityLabel?: string;  style?: StyleProp<ViewStyle>;} const SIZES = {  sm: { h: 36, px: 14, font: 13, depth: 4, gap: 6 },  md: { h: 46, px: 18, font: 15, depth: 5, gap: 8 },  lg: { h: 56, px: 24, font: 17, depth: 6, gap: 8 },} as const; /** Darken (amount < 0) or lighten (amount > 0) a #rrggbb colour. */function shade(hex: string, amount: number) {  const full = hex.length === 4 ? '#' + [...hex.slice(1)].map((c) => c + c).join('') : hex;  const n = parseInt(full.slice(1, 7), 16);  if (Number.isNaN(n)) return hex;  const mix = (c: number) => Math.round(amount < 0 ? c * (1 + amount) : c + (255 - c) * amount);  return '#' + [(n >> 16) & 255, (n >> 8) & 255, n & 255].map((c) => mix(c).toString(16).padStart(2, '0')).join('');} type Material = {  face: [string, string];  edge: [string, string];  ink: string;  rim: string;  outline: string;  shadow: number;}; const MATERIALS: Record<'light' | 'dark' | 'glass', Material> = {  light: { face: ['#ffffff', '#efeff2'], edge: ['#cfd0d6', '#b4b6bd'], ink: '#18181b', rim: 'rgba(255,255,255,0.9)', outline: 'rgba(0,0,0,0.07)', shadow: 0.28 },  dark: { face: ['#3b3b41', '#2a2a2f'], edge: ['#151517', '#0b0b0c'], ink: '#ffffff', rim: 'rgba(255,255,255,0.14)', outline: 'rgba(0,0,0,0.5)', shadow: 0.45 },  glass: {    face: ['rgba(255,255,255,0.32)', 'rgba(255,255,255,0.18)'],    edge: ['rgba(255,255,255,0.22)', 'rgba(255,255,255,0.12)'],    ink: '#ffffff',    rim: 'rgba(255,255,255,0.55)',    outline: 'rgba(255,255,255,0.35)',    shadow: 0.18,  },}; /** A material for a custom colour, built the same way the keycaps are. */const tinted = (hex: string): Material => ({  face: [shade(hex, 0.1), hex],  edge: [shade(hex, -0.3), shade(hex, -0.44)],  ink: readableOn(hex),  rim: 'rgba(255,255,255,0.35)',  outline: 'rgba(0,0,0,0.12)',  shadow: 0.35,}); /** * A physical, keycap-style button: a softly lit face on a darker edge that sinks in when pressed * — with a light haptic — and springs back. Light, dark or frosted-glass keycaps, or any colour. */export function Button3D({  children,  onPress,  appearance = 'auto',  color,  size = 'md',  shape = 'rounded',  depth: depthProp,  icon,  block,  loading,  disabled,  haptics = true,  accessibilityLabel,  style,}: Button3DProps) {  const { dark } = useKit();  const s = SIZES[size];  const depth = depthProp ?? s.depth;  const kind = appearance === 'auto' ? (dark ? 'light' : 'dark') : appearance;  const m = color ? tinted(color) : MATERIALS[kind];  const corner = shape === 'rounded' ? Math.round(s.h * 0.3) : s.h / 2;  const round = shape === 'circle';  const gid = `b3d-${color ? color.replace('#', '') : kind}`;   const press = useRef(new Animated.Value(0)).current;  const to = (v: number) => Animated.spring(press, { toValue: v, ...(v ? motion.snappy : motion.bouncy), useNativeDriver: true }).start();   const label = loading ? (    <ActivityIndicator color={m.ink} size="small" />  ) : isTextNode(children) ? (    // No numberOfLines: on a narrow fixed-width key ("esc", "Q") the label may use the padding    // instead of being cut to an ellipsis.    <Text style={{ color: m.ink, fontSize: round ? s.font + 4 : s.font, fontWeight: '600', letterSpacing: 0.1, flexShrink: 0, textAlign: 'center' }}>      {children}    </Text>  ) : (    children  );   const gradient = (id: string, [a, b]: [string, string]) => (    <Svg width="100%" height="100%" style={StyleSheet.absoluteFill} preserveAspectRatio="none" viewBox="0 0 1 1">      <Defs>        <LinearGradient id={id} x1="0" y1="0" x2="0" y2="1">          <Stop offset="0" stopColor={a} />          <Stop offset="1" stopColor={b} />        </LinearGradient>      </Defs>      <Rect width="1" height="1" fill={`url(#${id})`} />    </Svg>  );   return (    <Pressable      onPress={() => {        // Android has no touch delay inside scroll views, so the tap is only felt once it's a press.        if (haptics && Platform.OS === 'android') Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {});        onPress?.();      }}      onPressIn={() => {        to(1);        // iOS delays touches in scroll views, so a key-down tap here never fires for a scroll.        if (haptics && Platform.OS === 'ios') Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {});      }}      onPressOut={() => to(0)}      disabled={disabled || loading}      accessibilityRole="button"      accessibilityLabel={accessibilityLabel}      accessibilityState={{ disabled: !!disabled, busy: !!loading }}      hitSlop={s.h < 44 ? { top: (44 - s.h) / 2, bottom: (44 - s.h) / 2 } : undefined}      style={[{ alignSelf: block ? 'stretch' : undefined, opacity: disabled ? 0.45 : 1 }, style]}    >      <View style={{ paddingBottom: depth, width: round ? s.h : undefined }}>        {/* Contact shadow on the surface: tightens and fades as the key goes down. */}        <Animated.View          pointerEvents="none"          style={[            styles.fill,            {              top: depth,              borderRadius: corner,              backgroundColor: m.edge[1],              shadowOpacity: m.shadow,              opacity: press.interpolate({ inputRange: [0, 1], outputRange: [1, 0.55] }),            },            styles.shadow,          ]}        />        {/* The edge: the keycap's darker side. */}        {/* elevation (no background → no shadow) keeps the edge and face above the shadow layer on Android. */}        <View style={[styles.fill, { top: depth, borderRadius: corner, overflow: 'hidden', elevation: 3 }]}>{gradient(`${gid}-e`, m.edge)}</View>        <Animated.View          style={[            styles.face,            {              height: s.h,              paddingHorizontal: round ? 0 : s.px,              gap: s.gap,              borderRadius: corner,              elevation: 4,              transform: [{ translateY: press.interpolate({ inputRange: [0, 1], outputRange: [0, depth] }) }],            },          ]}        >          {gradient(`${gid}-f`, m.face)}          {/* A bright rim along the top edge and a hairline outline sell the bevel. */}          <View pointerEvents="none" style={[styles.fill, { borderRadius: corner, borderTopWidth: 1, borderTopColor: m.rim }]} />          <View pointerEvents="none" style={[styles.fill, { borderRadius: corner, borderWidth: StyleSheet.hairlineWidth, borderColor: m.outline }]} />          {loading ? null : icon}          {label}        </Animated.View>      </View>    </Pressable>  );} const styles = StyleSheet.create({  fill: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 },  shadow: { shadowColor: '#000', shadowRadius: 8, shadowOffset: { width: 0, height: 5 }, elevation: 3 },  face: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', overflow: 'hidden' },});