Editorial news home: filter chips, photo-hero lead story, latest list, bookmarks
import * as React from "react";
import { useState } from "react";
import { ScrollView, View } from "react-native";
import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context";
import { Avatar, Chip, Glass, Icon, Image, Text } from "@lumioui/ui";
import { useHaptic } from "@lumioui/core";
import { Display, Mount, Press, StoryScrim, World } from "../shared";
export interface Article {
id: string;
category: string;
title: string;
source: string;
sourceAvatar: string;
time: string;
imageUrl: string;
readMinutes: number;
}
export interface NewsEditorialScreenProps {
articles?: Article[];
onArticle?: (article: Article) => void;
onBack?: () => void;
}
const img = (id: string, w = 800, h = 500) =>
`https://images.unsplash.com/${id}?w=${w}&h=${h}&fit=crop&q=70&auto=format`;
const ARTICLES: Article[] = [
{
id: "1",
category: "Design",
title: "The quiet rise of calm interfaces",
source: "Pixel Press",
sourceAvatar: "https://i.pravatar.cc/100?img=5",
time: "2h ago",
imageUrl: img("photo-1618005182384-a83a8bd57fbe"),
readMinutes: 6,
},
{
id: "2",
category: "Tech",
title: "On-device AI redraws the mobile map",
source: "Circuit Daily",
sourceAvatar: "https://i.pravatar.cc/100?img=8",
time: "4h ago",
imageUrl: img("photo-1550745165-9bc0b252726f"),
readMinutes: 8,
},
{
id: "3",
category: "Culture",
title: "Why small studios are winning awards season",
source: "Frame Weekly",
sourceAvatar: "https://i.pravatar.cc/100?img=14",
time: "Yesterday",
imageUrl: img("photo-1489599849927-2ee91cede3ba"),
readMinutes: 5,
},
{
id: "4",
category: "Tech",
title: "The chip wars move to the datacenter edge",
source: "Circuit Daily",
sourceAvatar: "https://i.pravatar.cc/100?img=17",
time: "8h ago",
imageUrl: img("photo-1518770660439-4636190af475"),
readMinutes: 11,
},
{
id: "5",
category: "Design",
title: "Typography is the interface now",
source: "Pixel Press",
sourceAvatar: "https://i.pravatar.cc/100?img=32",
time: "Yesterday",
imageUrl: img("photo-1561070791-2526d30994b5"),
readMinutes: 4,
},
{
id: "6",
category: "Culture",
title: "Vinyl outsold streaming revenue again",
source: "Frame Weekly",
sourceAvatar: "https://i.pravatar.cc/100?img=45",
time: "2d ago",
imageUrl: img("photo-1483412033650-1015ddeb83d1"),
readMinutes: 7,
},
];
const FILTERS = ["All", "Design", "Tech", "Culture"];
/** Editorial news — magazine idiom (Kretya/Behance): category chips that
* really filter, a photo-hero lead story with chip overlay + source row,
* then a compact latest list. Bookmarks toggle. */
export function NewsEditorialScreen({
articles = ARTICLES,
onArticle,
onBack,
}: NewsEditorialScreenProps) {
const haptics = useHaptic();
const insets = useSafeAreaInsets();
const [filter, setFilter] = useState<(typeof FILTERS)[number]>("All");
const [saved, setSaved] = useState<Record<string, boolean>>({});
const visible = filter === "All" ? articles : articles.filter((a) => a.category === filter);
const lead = visible[0];
const rest = visible.slice(1);
const toggleSave = (id: string) => {
haptics.selection();
setSaved((s) => ({ ...s, [id]: !s[id] }));
};
return (
<World primary="#65A30D">
<SafeAreaView className="flex-1 bg-[#F4F5F9] dark:bg-[#111114]" edges={["top"]}>
<View className="flex-row items-center justify-between px-6 pt-2">
<Display className="text-4xl">Today</Display>
<View className="items-end">
{/* Edition line — editorial feeds date themselves. */}
<Text variant="muted" className="text-[10px] uppercase tracking-widest">
Evening edition
</Text>
<Text variant="muted" className="text-xs">
{new Date().toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" })}
</Text>
</View>
<Press
onPress={() => {
haptics.light();
onBack?.();
}}
accessibilityLabel="Go back"
accessibilityRole="button"
className="h-10 w-10 items-center justify-center overflow-hidden rounded-full"
>
<Glass interactive effect="clear" sheen={0.5} className="absolute inset-0 rounded-full" pointerEvents="none" />
<Icon name="chevron-left" size="md" color="foreground" />
</Press>
</View>
{/* Category chips — filter the feed. */}
<ScrollView
horizontal
className="grow-0"
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ gap: 8, paddingHorizontal: 24, paddingVertical: 14 }}
>
{FILTERS.map((f) => (
<Chip
key={f}
variant={filter === f ? "assist" : "filter"}
onPress={() => {
haptics.selection();
setFilter(f);
}}
>
{f}
</Chip>
))}
</ScrollView>
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: insets.bottom + 24 }}
>
{/* Lead story — photo card, category chip on scrim, source row. */}
{lead ? (
<View className="px-6">
<Press
onPress={() => {
haptics.light();
onArticle?.(lead);
}}
accessibilityLabel={lead.title}
accessibilityRole="button"
className="relative overflow-hidden rounded-3xl"
>
<Image
source={{ uri: lead.imageUrl }}
className="h-56 w-full bg-muted"
accessibilityLabel={lead.title}
/>
<View className="absolute inset-x-0 bottom-0 h-28">
<StoryScrim edge="bottom" />
</View>
<Glass interactive effect="clear" className="absolute left-4 top-4 rounded-full px-3 py-1.5">
<Text className="text-xs font-semibold text-white">{lead.category}</Text>
</Glass>
<View className="absolute bottom-0 left-0 right-0 p-4">
<Text className="text-xl font-semibold leading-7 text-white" numberOfLines={2}>
{lead.title}
</Text>
<View className="mt-2 flex-row items-center gap-2">
<Avatar size="sm" source={{ uri: lead.sourceAvatar }} fallback={lead.source} alt={lead.source} />
<Text className="text-xs text-white/80">
{lead.source} · {lead.time} · {lead.readMinutes} min read
</Text>
</View>
</View>
</Press>
</View>
) : null}
{/* Latest list. */}
<Text variant="large" className="mt-6 px-6">
Latest
</Text>
<Mount className="mt-2 gap-1 px-6">
{rest.map((a) => (
<View key={a.id} className="flex-row items-center gap-3 py-3">
<Image
source={{ uri: a.imageUrl }}
className="h-16 w-20 rounded-2xl bg-muted"
accessibilityLabel={a.title}
/>
<View className="flex-1">
<Text className="text-xs font-semibold text-primary">{a.category}</Text>
<Text className="mt-0.5 font-medium leading-5" numberOfLines={2}>
{a.title}
</Text>
<Text variant="small" className="mt-0.5 text-muted-foreground">
{a.source} · {a.time}
</Text>
</View>
<Press
onPress={() => toggleSave(a.id)}
accessibilityLabel={saved[a.id] ? "Remove bookmark" : "Bookmark"}
accessibilityRole="button"
className="h-9 w-9 items-center justify-center"
hitSlop={8}
>
<Icon
name="bookmark"
size="md"
color={saved[a.id] ? "primary" : "mutedForeground"}
filled={!!saved[a.id]}
/>
</Press>
</View>
))}
</Mount>
</ScrollView>
</SafeAreaView>
</World>
);
}
NewsEditorialScreen.displayName = "NewsEditorialScreen";
Recreate the "News — Editorial Screen" screen in React Native using LumioUI.
Goal: Editorial news home: filter chips, photo-hero lead story, latest list, bookmarks Match the reference implementation's layout, behavior, and motion exactly — this is a production screen, not a mockup.
## Setup
Fastest path — install the screen plus its component deps:
```bash
npx lumioui add misc-news-editorial
```
Or pull just the components: `npx lumioui add avatar chip glass icon image text`
Native packages:
- @lumioui/ui@latest
- @lumioui/core@latest
- react-native-safe-area-context@*
- react-native-reanimated@*
- react-native-svg@*
- nativewind@*
Stack assumptions: Expo SDK 54 / RN 0.81, Reanimated 4, NativeWind v4, TypeScript strict, Expo Go compatible.
## Components — how, where, when
### `avatar` — Circular user image with fallback initials and status dot.
- `<Avatar>` ×1
### `chip` — Compact selectable pill with press scale and ink overlay.
- `<Chip>` ×1
### `glass` — iOS 26 liquid-glass surface — real glass via expo-glass-effect (Expo Go) or @callstack/liquid-glass (dev builds), SVG refraction + specular rim on web, frosted fallback elsewhere; GlassContainer melts adjacent panes into one blob
- `<Glass>` ×2
### `icon` — Lucide icon set mapped to theme colors and sizes.
- `<Icon>` ×2
### `image` — Cached image with placeholder, fallback and aspect handling.
- `<Image>` ×2
### `text` — Themed type scale from caption to display.
- `<Text>` ×9 — Edition line — editorial feeds date themselves.
## Conventions (non-negotiable)
- Styling via NativeWind `className` with semantic tokens only: `bg-background`, `text-foreground`, `bg-card`, `text-card-foreground`, `bg-secondary`, `bg-muted`, `text-muted-foreground`, `bg-accent`, `bg-destructive`, `border-border`. Hard-coded colors only inside media overlays (`text-white`, `bg-black/N`).
- Motion via the token presets from `@lumioui/core` (`spring.press`, `spring.surface`, `spring.glide`, `timing.fast`, `timing.slow`, …) — no bespoke spring configs or durations.
- Gate transform motion behind `useReducedMotion()`; keep opacity/color feedback, drop travel and overshoot.
- Haptics via `useHaptic()`: `light` on press, `selection` on toggle/select, `warning` on invalid input. Never import expo-haptics directly.
- Press feedback via `usePressAnimation()` (~0.97 scale), not `activeOpacity` tricks.
- a11y: `accessibilityRole` + `accessibilityLabel` on every control, touch targets ≥44pt.
- Entrance choreography: staggered `Mount` wrappers (delay 0/60/120…) so regions land in sequence instead of popping in at once.
- Wrap the screen in `SafeAreaView`; keep dark mode working via the semantic tokens.
## Deliverable
One file: `screens/misc/news-editorial.tsx` exporting NewsEditorialScreen. Preserve the props interface — the host app wires the callbacks (navigation, submit handlers) through them.
## Reference implementation
The LumioUI source for this screen — reproduce it faithfully:
```tsx
import * as React from "react";
import { useState } from "react";
import { ScrollView, View } from "react-native";
import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context";
import { Avatar, Chip, Glass, Icon, Image, Text } from "@lumioui/ui";
import { useHaptic } from "@lumioui/core";
import { Display, Mount, Press, StoryScrim, World } from "../shared";
export interface Article {
id: string;
category: string;
title: string;
source: string;
sourceAvatar: string;
time: string;
imageUrl: string;
readMinutes: number;
}
export interface NewsEditorialScreenProps {
articles?: Article[];
onArticle?: (article: Article) => void;
onBack?: () => void;
}
const img = (id: string, w = 800, h = 500) =>
`https://images.unsplash.com/${id}?w=${w}&h=${h}&fit=crop&q=70&auto=format`;
const ARTICLES: Article[] = [
{
id: "1",
category: "Design",
title: "The quiet rise of calm interfaces",
source: "Pixel Press",
sourceAvatar: "https://i.pravatar.cc/100?img=5",
time: "2h ago",
imageUrl: img("photo-1618005182384-a83a8bd57fbe"),
readMinutes: 6,
},
{
id: "2",
category: "Tech",
title: "On-device AI redraws the mobile map",
source: "Circuit Daily",
sourceAvatar: "https://i.pravatar.cc/100?img=8",
time: "4h ago",
imageUrl: img("photo-1550745165-9bc0b252726f"),
readMinutes: 8,
},
{
id: "3",
category: "Culture",
title: "Why small studios are winning awards season",
source: "Frame Weekly",
sourceAvatar: "https://i.pravatar.cc/100?img=14",
time: "Yesterday",
imageUrl: img("photo-1489599849927-2ee91cede3ba"),
readMinutes: 5,
},
{
id: "4",
category: "Tech",
title: "The chip wars move to the datacenter edge",
source: "Circuit Daily",
sourceAvatar: "https://i.pravatar.cc/100?img=17",
time: "8h ago",
imageUrl: img("photo-1518770660439-4636190af475"),
readMinutes: 11,
},
{
id: "5",
category: "Design",
title: "Typography is the interface now",
source: "Pixel Press",
sourceAvatar: "https://i.pravatar.cc/100?img=32",
time: "Yesterday",
imageUrl: img("photo-1561070791-2526d30994b5"),
readMinutes: 4,
},
{
id: "6",
category: "Culture",
title: "Vinyl outsold streaming revenue again",
source: "Frame Weekly",
sourceAvatar: "https://i.pravatar.cc/100?img=45",
time: "2d ago",
imageUrl: img("photo-1483412033650-1015ddeb83d1"),
readMinutes: 7,
},
];
const FILTERS = ["All", "Design", "Tech", "Culture"];
/** Editorial news — magazine idiom (Kretya/Behance): category chips that
* really filter, a photo-hero lead story with chip overlay + source row,
* then a compact latest list. Bookmarks toggle. */
export function NewsEditorialScreen({
articles = ARTICLES,
onArticle,
onBack,
}: NewsEditorialScreenProps) {
const haptics = useHaptic();
const insets = useSafeAreaInsets();
const [filter, setFilter] = useState<(typeof FILTERS)[number]>("All");
const [saved, setSaved] = useState<Record<string, boolean>>({});
const visible = filter === "All" ? articles : articles.filter((a) => a.category === filter);
const lead = visible[0];
const rest = visible.slice(1);
const toggleSave = (id: string) => {
haptics.selection();
setSaved((s) => ({ ...s, [id]: !s[id] }));
};
return (
<World primary="#65A30D">
<SafeAreaView className="flex-1 bg-[#F4F5F9] dark:bg-[#111114]" edges={["top"]}>
<View className="flex-row items-center justify-between px-6 pt-2">
<Display className="text-4xl">Today</Display>
<View className="items-end">
{/* Edition line — editorial feeds date themselves. */}
<Text variant="muted" className="text-[10px] uppercase tracking-widest">
Evening edition
</Text>
<Text variant="muted" className="text-xs">
{new Date().toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" })}
</Text>
</View>
<Press
onPress={() => {
haptics.light();
onBack?.();
}}
accessibilityLabel="Go back"
accessibilityRole="button"
className="h-10 w-10 items-center justify-center overflow-hidden rounded-full"
>
<Glass interactive effect="clear" sheen={0.5} className="absolute inset-0 rounded-full" pointerEvents="none" />
<Icon name="chevron-left" size="md" color="foreground" />
</Press>
</View>
{/* Category chips — filter the feed. */}
<ScrollView
horizontal
className="grow-0"
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ gap: 8, paddingHorizontal: 24, paddingVertical: 14 }}
>
{FILTERS.map((f) => (
<Chip
key={f}
variant={filter === f ? "assist" : "filter"}
onPress={() => {
haptics.selection();
setFilter(f);
}}
>
{f}
</Chip>
))}
</ScrollView>
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: insets.bottom + 24 }}
>
{/* Lead story — photo card, category chip on scrim, source row. */}
{lead ? (
<View className="px-6">
<Press
onPress={() => {
haptics.light();
onArticle?.(lead);
}}
accessibilityLabel={lead.title}
accessibilityRole="button"
className="relative overflow-hidden rounded-3xl"
>
<Image
source={{ uri: lead.imageUrl }}
className="h-56 w-full bg-muted"
accessibilityLabel={lead.title}
/>
<View className="absolute inset-x-0 bottom-0 h-28">
<StoryScrim edge="bottom" />
</View>
<Glass interactive effect="clear" className="absolute left-4 top-4 rounded-full px-3 py-1.5">
<Text className="text-xs font-semibold text-white">{lead.category}</Text>
</Glass>
<View className="absolute bottom-0 left-0 right-0 p-4">
<Text className="text-xl font-semibold leading-7 text-white" numberOfLines={2}>
{lead.title}
</Text>
<View className="mt-2 flex-row items-center gap-2">
<Avatar size="sm" source={{ uri: lead.sourceAvatar }} fallback={lead.source} alt={lead.source} />
<Text className="text-xs text-white/80">
{lead.source} · {lead.time} · {lead.readMinutes} min read
</Text>
</View>
</View>
</Press>
</View>
) : null}
{/* Latest list. */}
<Text variant="large" className="mt-6 px-6">
Latest
</Text>
<Mount className="mt-2 gap-1 px-6">
{rest.map((a) => (
<View key={a.id} className="flex-row items-center gap-3 py-3">
<Image
source={{ uri: a.imageUrl }}
className="h-16 w-20 rounded-2xl bg-muted"
accessibilityLabel={a.title}
/>
<View className="flex-1">
<Text className="text-xs font-semibold text-primary">{a.category}</Text>
<Text className="mt-0.5 font-medium leading-5" numberOfLines={2}>
{a.title}
</Text>
<Text variant="small" className="mt-0.5 text-muted-foreground">
{a.source} · {a.time}
</Text>
</View>
<Press
onPress={() => toggleSave(a.id)}
accessibilityLabel={saved[a.id] ? "Remove bookmark" : "Bookmark"}
accessibilityRole="button"
className="h-9 w-9 items-center justify-center"
hitSlop={8}
>
<Icon
name="bookmark"
size="md"
color={saved[a.id] ? "primary" : "mutedForeground"}
filled={!!saved[a.id]}
/>
</Press>
</View>
))}
</Mount>
</ScrollView>
</SafeAreaView>
</World>
);
}
NewsEditorialScreen.displayName = "NewsEditorialScreen";
```
Screen: https://lumioui.dev/screens/misc-news-editorial